P3: Bucket Policy implementation complete
- BucketPolicy struct with Version + Statement array - PolicyStatement: Effect, Principal, Action, Resource, Condition - Principal matching (wildcard + user-specific) - Action/Resource pattern matching with wildcards - GetBucketPolicy: GET /s3/policy/:bucket - PutBucketPolicy: PUT /s3/policy/:bucket - DeleteBucketPolicy: DELETE /s3/policy/:bucket - Policy persistence to data/s3_policies/:bucket/policy.json - check_bucket_policy() for authorization - 6 unit tests Tests: 299 passed, 0 failed
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -847,3 +847,92 @@ fn parse_complete_multipart_xml(xml: &[u8]) -> Option<Vec<(u32, String)>> {
|
||||
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
// ===== Bucket Policy Support =====
|
||||
|
||||
use crate::s3_policy::BucketPolicy;
|
||||
|
||||
static BUCKET_POLICIES: once_cell::sync::Lazy<Arc<RwLock<HashMap<String, BucketPolicy>>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(HashMap::new())));
|
||||
|
||||
pub async fn get_bucket_policy(
|
||||
Path(bucket): Path<String>,
|
||||
State(_state): State<crate::server::AppState>,
|
||||
) -> 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<String>,
|
||||
State(_state): State<crate::server::AppState>,
|
||||
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<String>,
|
||||
State(_state): State<crate::server::AppState>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
252
markbase-core/src/s3_policy.rs
Normal file
252
markbase-core/src/s3_policy.rs
Normal file
@@ -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<PolicyStatement>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[serde(rename = "Effect")]
|
||||
pub effect: PolicyEffect,
|
||||
#[serde(rename = "Principal")]
|
||||
pub principal: Principal,
|
||||
#[serde(rename = "Action")]
|
||||
pub action: Vec<String>,
|
||||
#[serde(rename = "Resource")]
|
||||
pub resource: Vec<String>,
|
||||
#[serde(rename = "Condition")]
|
||||
pub condition: Option<HashMap<String, HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
#[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<String, Vec<String>>),
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,10 @@ pub async fn run(port: u16, file: Option<String>) -> 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))
|
||||
|
||||
Reference in New Issue
Block a user