diff --git a/markbase-core/src/lib.rs b/markbase-core/src/lib.rs index 7707392..1ebe040 100644 --- a/markbase-core/src/lib.rs +++ b/markbase-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod rsync; pub mod s3; pub mod s3_auth; pub mod s3_config; +pub mod s3_policy; pub mod s3_xml; pub mod scan; pub mod server; diff --git a/markbase-core/src/s3.rs b/markbase-core/src/s3.rs index 32b0b82..a058410 100644 --- a/markbase-core/src/s3.rs +++ b/markbase-core/src/s3.rs @@ -847,3 +847,92 @@ fn parse_complete_multipart_xml(xml: &[u8]) -> Option> { Some(parts) } + +// ===== Bucket Policy Support ===== + +use crate::s3_policy::BucketPolicy; + +static BUCKET_POLICIES: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); + +pub async fn get_bucket_policy( + Path(bucket): Path, + State(_state): State, +) -> impl IntoResponse { + let policies = BUCKET_POLICIES.read().await; + let policy = policies.get(&bucket); + + if policy.is_none() { + return (StatusCode::NOT_FOUND, "Bucket policy not found").into_response(); + } + + let policy = policy.unwrap(); + let json = serde_json::to_string_pretty(policy) + .unwrap_or_else(|_| "{}".to_string()); + + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", "application/json".parse().unwrap()); + + (StatusCode::OK, headers, json).into_response() +} + +pub async fn put_bucket_policy( + Path(bucket): Path, + State(_state): State, + body: Body, +) -> impl IntoResponse { + let body_bytes = axum::body::to_bytes(body, 100000).await.ok(); + + if body_bytes.is_none() { + return (StatusCode::BAD_REQUEST, "Empty body").into_response(); + } + + let policy: BucketPolicy = match serde_json::from_slice(&body_bytes.unwrap()) { + Ok(p) => p, + Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid policy JSON: {}", e)).into_response(), + }; + + // Persist to file first (before moving policy) + let policy_path = format!("data/s3_policies/{}/policy.json", bucket); + if let Err(e) = std::fs::create_dir_all(format!("data/s3_policies/{}", bucket)) { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create policy dir: {}", e)).into_response(); + } + + let policy_json = serde_json::to_string_pretty(&policy).unwrap_or_default(); + if let Err(e) = std::fs::write(&policy_path, &policy_json) { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write policy: {}", e)).into_response(); + } + + // Now move policy to in-memory storage + { + let mut policies = BUCKET_POLICIES.write().await; + policies.insert(bucket.clone(), policy); + } + + (StatusCode::NO_CONTENT, HeaderMap::new()).into_response() +} + +pub async fn delete_bucket_policy( + Path(bucket): Path, + State(_state): State, +) -> impl IntoResponse { + { + let mut policies = BUCKET_POLICIES.write().await; + policies.remove(&bucket); + } + + let policy_path = format!("data/s3_policies/{}/policy.json", bucket); + let _ = std::fs::remove_file(&policy_path); + + (StatusCode::NO_CONTENT, HeaderMap::new()).into_response() +} + +pub fn check_bucket_policy(bucket: &str, action: &str, resource: &str, user_id: &str) -> bool { + let policies = BUCKET_POLICIES.blocking_read(); + + if let Some(policy) = policies.get(bucket) { + return policy.is_allowed(action, resource, user_id); + } + + true +} diff --git a/markbase-core/src/s3_policy.rs b/markbase-core/src/s3_policy.rs new file mode 100644 index 0000000..e3faa8b --- /dev/null +++ b/markbase-core/src/s3_policy.rs @@ -0,0 +1,252 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BucketPolicy { + #[serde(rename = "Version")] + pub version: String, + #[serde(rename = "Statement")] + pub statement: Vec, +} + +impl Default for BucketPolicy { + fn default() -> Self { + Self { + version: "2012-10-17".to_string(), + statement: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyStatement { + #[serde(rename = "Sid")] + pub sid: Option, + #[serde(rename = "Effect")] + pub effect: PolicyEffect, + #[serde(rename = "Principal")] + pub principal: Principal, + #[serde(rename = "Action")] + pub action: Vec, + #[serde(rename = "Resource")] + pub resource: Vec, + #[serde(rename = "Condition")] + pub condition: Option>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PolicyEffect { + Allow, + Deny, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Principal { + Wildcard(String), + Specific(HashMap>), +} + +impl Principal { + pub fn is_public(&self) -> bool { + match self { + Principal::Wildcard(s) => s == "*", + Principal::Specific(_) => false, + } + } + + pub fn matches_user(&self, user_id: &str) -> bool { + match self { + Principal::Wildcard(s) => s == "*", + Principal::Specific(map) => { + if let Some(aws_users) = map.get("AWS") { + aws_users.iter().any(|u| u == user_id || u == "*") + } else { + false + } + } + } + } +} + +impl BucketPolicy { + pub fn new() -> Self { + Self::default() + } + + pub fn is_allowed(&self, action: &str, resource: &str, user_id: &str) -> bool { + let mut allowed = false; + + for stmt in &self.statement { + if stmt.matches_action(action) && stmt.matches_resource(resource) { + if stmt.principal.matches_user(user_id) { + match stmt.effect { + PolicyEffect::Allow => { + if stmt.matches_condition(user_id) { + allowed = true; + } + } + PolicyEffect::Deny => { + if stmt.matches_condition(user_id) { + return false; + } + } + } + } + } + } + + allowed + } +} + +impl PolicyStatement { + pub fn matches_action(&self, action: &str) -> bool { + self.action.iter().any(|a| { + a == action || a == "s3:*" || a == "*" || + (a.ends_with('*') && action.starts_with(&a[..a.len()-1])) + }) + } + + pub fn matches_resource(&self, resource: &str) -> bool { + self.resource.iter().any(|r| { + r == resource || r == "*" || + (r.ends_with('*') && resource.starts_with(&r[..r.len()-1])) + }) + } + + pub fn matches_condition(&self, _user_id: &str) -> bool { + if let Some(cond) = &self.condition { + for (operator, values) in cond { + for (key, value) in values { + if operator == "StringEquals" && key == "aws:userid" { + if value != _user_id { + return false; + } + } + } + } + } + true + } +} + +pub fn default_public_policy(bucket: &str) -> BucketPolicy { + BucketPolicy { + version: "2012-10-17".to_string(), + statement: vec![ + PolicyStatement { + sid: Some("PublicRead".to_string()), + effect: PolicyEffect::Allow, + principal: Principal::Wildcard("*".to_string()), + action: vec!["s3:GetObject".to_string()], + resource: vec![format!("arn:aws:s3:::{}/*", bucket)], + condition: None, + }, + ], + } +} + +pub fn default_private_policy(bucket: &str, user_id: &str) -> BucketPolicy { + BucketPolicy { + version: "2012-10-17".to_string(), + statement: vec![ + PolicyStatement { + sid: Some("OwnerFullAccess".to_string()), + effect: PolicyEffect::Allow, + principal: Principal::Specific({ + let mut map = HashMap::new(); + map.insert("AWS".to_string(), vec![format!("arn:aws:iam:::user/{}", user_id)]); + map + }), + action: vec!["s3:*".to_string()], + resource: vec![ + format!("arn:aws:s3:::{}/*", bucket), + format!("arn:aws:s3:::{}/*", bucket), + ], + condition: None, + }, + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_policy_parse() { + let policy_json = r#"{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::mybucket/*"] + } + ] + }"#; + + let policy: BucketPolicy = serde_json::from_str(policy_json).unwrap(); + assert_eq!(policy.version, "2012-10-17"); + assert_eq!(policy.statement.len(), 1); + assert_eq!(policy.statement[0].effect, PolicyEffect::Allow); + } + + #[test] + fn test_policy_evaluation_allow() { + let policy = default_public_policy("testbucket"); + assert!(policy.is_allowed("s3:GetObject", "arn:aws:s3:::testbucket/file.txt", "anonymous")); + } + + #[test] + fn test_policy_evaluation_deny() { + let policy = default_public_policy("testbucket"); + assert!(!policy.is_allowed("s3:PutObject", "arn:aws:s3:::testbucket/file.txt", "anonymous")); + } + + #[test] + fn test_action_wildcard() { + let stmt = PolicyStatement { + sid: None, + effect: PolicyEffect::Allow, + principal: Principal::Wildcard("*".to_string()), + action: vec!["s3:*".to_string()], + resource: vec!["*".to_string()], + condition: None, + }; + + assert!(stmt.matches_action("s3:GetObject")); + assert!(stmt.matches_action("s3:PutObject")); + assert!(stmt.matches_action("s3:DeleteObject")); + } + + #[test] + fn test_resource_pattern() { + let stmt = PolicyStatement { + sid: None, + effect: PolicyEffect::Allow, + principal: Principal::Wildcard("*".to_string()), + action: vec!["s3:GetObject".to_string()], + resource: vec!["arn:aws:s3:::mybucket/home/*".to_string()], + condition: None, + }; + + assert!(stmt.matches_resource("arn:aws:s3:::mybucket/home/user/file.txt")); + assert!(!stmt.matches_resource("arn:aws:s3:::mybucket/public/file.txt")); + } + + #[test] + fn test_principal_user_match() { + let principal = Principal::Specific({ + let mut map = HashMap::new(); + map.insert("AWS".to_string(), vec!["warren".to_string()]); + map + }); + + assert!(principal.matches_user("warren")); + assert!(!principal.matches_user("demo")); + } +} \ No newline at end of file diff --git a/markbase-core/src/server.rs b/markbase-core/src/server.rs index a8e337c..d879f43 100644 --- a/markbase-core/src/server.rs +++ b/markbase-core/src/server.rs @@ -251,6 +251,10 @@ pub async fn run(port: u16, file: Option) -> anyhow::Result<()> { .route("/s3/multipart/:bucket/*key/part", put(crate::s3::upload_part)) .route("/s3/multipart/:bucket/*key/complete", post(crate::s3::complete_multipart_upload)) .route("/s3/multipart/:bucket/*key/abort", delete(crate::s3::abort_multipart_upload)) + // Bucket policy endpoints + .route("/s3/policy/:bucket", get(crate::s3::get_bucket_policy)) + .route("/s3/policy/:bucket", put(crate::s3::put_bucket_policy)) + .route("/s3/policy/:bucket", delete(crate::s3::delete_bucket_policy)) // Shell and Metrics API endpoints (public for monitoring) .route("/api/v2/shell/status", get(shell_status_handler)) .route("/api/v2/metrics", get(metrics_handler))