P3: Bucket Policy implementation complete
Some checks failed
Test / build (push) Has been cancelled
Test / test (push) Has been cancelled

- 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:
Warren
2026-06-21 22:50:53 +08:00
parent ca0f541a79
commit 02d98419e1
4 changed files with 346 additions and 0 deletions

View File

@@ -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;

View File

@@ -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
}

View 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"));
}
}

View File

@@ -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))