feat(bucket-tenants): add persisted bucket-scoped tenant credentials with bucket export and import APIs
This commit is contained in:
@@ -57,6 +57,7 @@ pub struct RequestContext {
|
||||
pub action: StorageAction,
|
||||
pub bucket: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub source_bucket: Option<String>,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -90,6 +91,7 @@ pub fn resolve_action(req: &Request<Incoming>) -> RequestContext {
|
||||
action: StorageAction::ListAllMyBuckets,
|
||||
bucket: None,
|
||||
key: None,
|
||||
source_bucket: None,
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
@@ -113,6 +115,7 @@ pub fn resolve_action(req: &Request<Incoming>) -> RequestContext {
|
||||
action,
|
||||
bucket: Some(bucket),
|
||||
key: None,
|
||||
source_bucket: None,
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
@@ -123,6 +126,18 @@ pub fn resolve_action(req: &Request<Incoming>) -> RequestContext {
|
||||
let has_part_number = query.contains_key("partNumber");
|
||||
let has_upload_id = query.contains_key("uploadId");
|
||||
let has_uploads = query.contains_key("uploads");
|
||||
let source_bucket = if has_copy_source {
|
||||
req.headers()
|
||||
.get("x-amz-copy-source")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|source| {
|
||||
let source = source.trim_start_matches('/');
|
||||
let first_slash = source.find('/').unwrap_or(source.len());
|
||||
percent_decode(&source[..first_slash])
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let action = match &method {
|
||||
&Method::PUT if has_part_number && has_upload_id => StorageAction::UploadPart,
|
||||
@@ -141,12 +156,14 @@ pub fn resolve_action(req: &Request<Incoming>) -> RequestContext {
|
||||
action,
|
||||
bucket: Some(bucket),
|
||||
key: Some(key),
|
||||
source_bucket,
|
||||
}
|
||||
}
|
||||
_ => RequestContext {
|
||||
action: StorageAction::ListAllMyBuckets,
|
||||
bucket: None,
|
||||
key: None,
|
||||
source_bucket: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+180
-20
@@ -3,6 +3,8 @@ use hyper::body::Incoming;
|
||||
use hyper::Request;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::config::{AuthConfig, Credential};
|
||||
@@ -14,6 +16,7 @@ type HmacSha256 = Hmac<Sha256>;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedIdentity {
|
||||
pub access_key_id: String,
|
||||
pub bucket_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed components of an AWS4-HMAC-SHA256 Authorization header.
|
||||
@@ -56,11 +59,7 @@ pub fn verify_request(
|
||||
.headers()
|
||||
.get("x-amz-date")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.or_else(|| {
|
||||
req.headers()
|
||||
.get("date")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
})
|
||||
.or_else(|| req.headers().get("date").and_then(|v| v.to_str().ok()))
|
||||
.ok_or_else(|| StorageError::missing_security_header("Missing x-amz-date header"))?;
|
||||
|
||||
// Enforce 15-min clock skew
|
||||
@@ -77,10 +76,7 @@ pub fn verify_request(
|
||||
let canonical_request = build_canonical_request(req, &parsed.signed_headers, content_sha256);
|
||||
|
||||
// Build string to sign
|
||||
let scope = format!(
|
||||
"{}/{}/s3/aws4_request",
|
||||
parsed.date_stamp, parsed.region
|
||||
);
|
||||
let scope = format!("{}/{}/s3/aws4_request", parsed.date_stamp, parsed.region);
|
||||
let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
@@ -105,6 +101,7 @@ pub fn verify_request(
|
||||
|
||||
Ok(AuthenticatedIdentity {
|
||||
access_key_id: parsed.access_key_id,
|
||||
bucket_name: credential.bucket_name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,10 +128,9 @@ fn parse_auth_header(header: &str) -> Result<SigV4Header, StorageError> {
|
||||
}
|
||||
}
|
||||
|
||||
let credential_str = credential_str
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?;
|
||||
let signed_headers_str = signed_headers_str
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?;
|
||||
let credential_str = credential_str.ok_or_else(StorageError::authorization_header_malformed)?;
|
||||
let signed_headers_str =
|
||||
signed_headers_str.ok_or_else(StorageError::authorization_header_malformed)?;
|
||||
let signature = signature_str
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?
|
||||
.to_string();
|
||||
@@ -164,7 +160,10 @@ fn parse_auth_header(header: &str) -> Result<SigV4Header, StorageError> {
|
||||
}
|
||||
|
||||
/// Find a credential by access key ID.
|
||||
fn find_credential<'a>(access_key_id: &str, credentials: &'a [Credential]) -> Option<&'a Credential> {
|
||||
fn find_credential<'a>(
|
||||
access_key_id: &str,
|
||||
credentials: &'a [Credential],
|
||||
) -> Option<&'a Credential> {
|
||||
credentials
|
||||
.iter()
|
||||
.find(|c| c.access_key_id == access_key_id)
|
||||
@@ -174,20 +173,49 @@ fn find_credential<'a>(access_key_id: &str, credentials: &'a [Credential]) -> Op
|
||||
pub struct RuntimeCredentialStore {
|
||||
enabled: bool,
|
||||
credentials: RwLock<Vec<Credential>>,
|
||||
persistence_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialMetadata {
|
||||
pub access_key_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bucket_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BucketTenantMetadata {
|
||||
pub bucket_name: String,
|
||||
pub access_key_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
impl RuntimeCredentialStore {
|
||||
pub fn new(config: &AuthConfig) -> Self {
|
||||
Self {
|
||||
pub async fn new(
|
||||
config: &AuthConfig,
|
||||
persistence_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let credentials = match persistence_path.as_ref() {
|
||||
Some(path) if path.exists() => {
|
||||
let content = fs::read_to_string(path).await?;
|
||||
let credentials: Vec<Credential> = serde_json::from_str(&content)?;
|
||||
validate_credentials(&credentials)
|
||||
.map_err(|error| anyhow::anyhow!(error.message))?;
|
||||
credentials
|
||||
}
|
||||
_ => config.credentials.clone(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
enabled: config.enabled,
|
||||
credentials: RwLock::new(config.credentials.clone()),
|
||||
}
|
||||
credentials: RwLock::new(credentials),
|
||||
persistence_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
@@ -201,6 +229,8 @@ impl RuntimeCredentialStore {
|
||||
.iter()
|
||||
.map(|credential| CredentialMetadata {
|
||||
access_key_id: credential.access_key_id.clone(),
|
||||
bucket_name: credential.bucket_name.clone(),
|
||||
region: credential.region.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -209,11 +239,140 @@ impl RuntimeCredentialStore {
|
||||
self.credentials.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn replace_credentials(&self, credentials: Vec<Credential>) -> Result<(), StorageError> {
|
||||
pub async fn replace_credentials(
|
||||
&self,
|
||||
credentials: Vec<Credential>,
|
||||
) -> Result<(), StorageError> {
|
||||
validate_credentials(&credentials)?;
|
||||
self.persist_credentials(&credentials).await?;
|
||||
*self.credentials.write().await = credentials;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn replace_bucket_tenant_credential(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
mut credential: Credential,
|
||||
) -> Result<Credential, StorageError> {
|
||||
validate_bucket_scope(bucket_name)?;
|
||||
credential.bucket_name = Some(bucket_name.to_string());
|
||||
|
||||
let mut credentials = self.credentials.read().await.clone();
|
||||
if credentials.iter().any(|existing| {
|
||||
existing.access_key_id == credential.access_key_id
|
||||
&& existing.bucket_name.as_deref() != Some(bucket_name)
|
||||
}) {
|
||||
return Err(StorageError::invalid_request(
|
||||
"Credential accessKeyId is already assigned to another principal.",
|
||||
));
|
||||
}
|
||||
|
||||
credentials.retain(|existing| existing.bucket_name.as_deref() != Some(bucket_name));
|
||||
credentials.push(credential.clone());
|
||||
validate_credentials(&credentials)?;
|
||||
self.persist_credentials(&credentials).await?;
|
||||
*self.credentials.write().await = credentials;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
pub async fn remove_bucket_tenant_credentials(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
access_key_id: Option<&str>,
|
||||
) -> Result<usize, StorageError> {
|
||||
validate_bucket_scope(bucket_name)?;
|
||||
let mut credentials = self.credentials.read().await.clone();
|
||||
let before = credentials.len();
|
||||
credentials.retain(|credential| {
|
||||
if credential.bucket_name.as_deref() != Some(bucket_name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(access_key_id) = access_key_id {
|
||||
credential.access_key_id != access_key_id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
let removed = before.saturating_sub(credentials.len());
|
||||
if credentials.is_empty() {
|
||||
return Err(StorageError::invalid_request(
|
||||
"Cannot remove the last active credential.",
|
||||
));
|
||||
}
|
||||
self.persist_credentials(&credentials).await?;
|
||||
*self.credentials.write().await = credentials;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub async fn list_bucket_tenants(&self) -> Vec<BucketTenantMetadata> {
|
||||
let mut tenants: Vec<BucketTenantMetadata> = self
|
||||
.credentials
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|credential| {
|
||||
credential
|
||||
.bucket_name
|
||||
.as_ref()
|
||||
.map(|bucket_name| BucketTenantMetadata {
|
||||
bucket_name: bucket_name.clone(),
|
||||
access_key_id: credential.access_key_id.clone(),
|
||||
region: credential.region.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
tenants.sort_by(|a, b| {
|
||||
a.bucket_name
|
||||
.cmp(&b.bucket_name)
|
||||
.then_with(|| a.access_key_id.cmp(&b.access_key_id))
|
||||
});
|
||||
tenants
|
||||
}
|
||||
|
||||
pub async fn get_bucket_tenant_credential(&self, bucket_name: &str) -> Option<Credential> {
|
||||
self.credentials
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.find(|credential| credential.bucket_name.as_deref() == Some(bucket_name))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
async fn persist_credentials(&self, credentials: &[Credential]) -> Result<(), StorageError> {
|
||||
let Some(path) = self.persistence_path.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|error| StorageError::internal_error(&error.to_string()))?;
|
||||
}
|
||||
|
||||
let temp_path = path.with_extension("json.tmp");
|
||||
let json = serde_json::to_string_pretty(credentials)
|
||||
.map_err(|error| StorageError::internal_error(&error.to_string()))?;
|
||||
fs::write(&temp_path, json)
|
||||
.await
|
||||
.map_err(|error| StorageError::internal_error(&error.to_string()))?;
|
||||
fs::rename(&temp_path, path)
|
||||
.await
|
||||
.map_err(|error| StorageError::internal_error(&error.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_bucket_scope(bucket_name: &str) -> Result<(), StorageError> {
|
||||
if bucket_name.trim().is_empty() {
|
||||
return Err(StorageError::invalid_request(
|
||||
"Bucket tenant bucketName must not be empty.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_credentials(credentials: &[Credential]) -> Result<(), StorageError> {
|
||||
@@ -253,7 +412,8 @@ fn check_clock_skew(amz_date: &str) -> Result<(), StorageError> {
|
||||
let parsed = chrono::NaiveDateTime::parse_from_str(amz_date, "%Y%m%dT%H%M%SZ")
|
||||
.map_err(|_| StorageError::authorization_header_malformed())?;
|
||||
|
||||
let request_time = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(parsed, chrono::Utc);
|
||||
let request_time =
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(parsed, chrono::Utc);
|
||||
let now = chrono::Utc::now();
|
||||
let diff = (now - request_time).num_seconds().unsigned_abs();
|
||||
|
||||
|
||||
@@ -21,11 +21,10 @@ use super::quic_transport::QuicTransport;
|
||||
use super::shard_store::{ShardId, ShardStore};
|
||||
use super::state::{ClusterState, NodeStatus};
|
||||
use crate::storage::{
|
||||
storage_location_summary, BucketInfo, BucketSummary, ClusterDriveHealth,
|
||||
ClusterErasureHealth, ClusterHealth, ClusterPeerHealth, ClusterRepairHealth,
|
||||
CompleteMultipartResult, CopyResult, GetResult, HeadResult, ListObjectEntry,
|
||||
ListObjectsResult, MultipartUploadInfo, PutResult, RuntimeBucketStats,
|
||||
RuntimeStatsState, StorageLocationSummary, StorageStats,
|
||||
storage_location_summary, BucketInfo, BucketSummary, ClusterDriveHealth, ClusterErasureHealth,
|
||||
ClusterHealth, ClusterPeerHealth, ClusterRepairHealth, CompleteMultipartResult, CopyResult,
|
||||
GetResult, HeadResult, ListObjectEntry, ListObjectsResult, MultipartUploadInfo, PutResult,
|
||||
RuntimeBucketStats, RuntimeStatsState, StorageLocationSummary, StorageStats,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -170,7 +169,8 @@ impl DistributedStore {
|
||||
let peers = self.peer_health(&nodes);
|
||||
let drives = self.drive_health(&drive_states, &erasure_sets);
|
||||
let repairs = self.repair_health().await;
|
||||
let quorum_healthy = majority_healthy && self.quorum_is_healthy(&nodes, &drive_states, &erasure_sets);
|
||||
let quorum_healthy =
|
||||
majority_healthy && self.quorum_is_healthy(&nodes, &drive_states, &erasure_sets);
|
||||
|
||||
Ok(ClusterHealth {
|
||||
enabled: true,
|
||||
@@ -291,6 +291,69 @@ impl DistributedStore {
|
||||
Ok(PutResult { md5: md5_hex })
|
||||
}
|
||||
|
||||
pub async fn put_object_bytes(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
metadata: HashMap<String, String>,
|
||||
) -> Result<PutResult> {
|
||||
if !self.bucket_exists(bucket).await {
|
||||
return Err(crate::error::StorageError::no_such_bucket().into());
|
||||
}
|
||||
|
||||
let previous_size = self.manifest_size_if_exists(bucket, key).await;
|
||||
let erasure_set = self
|
||||
.state
|
||||
.get_erasure_set_for_object(bucket, key)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("No erasure sets available"))?;
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
for (chunk_index, chunk_data) in data
|
||||
.chunks(self.erasure_config.chunk_size_bytes)
|
||||
.enumerate()
|
||||
{
|
||||
let chunk_manifest = self
|
||||
.encode_and_distribute_chunk(
|
||||
&erasure_set,
|
||||
bucket,
|
||||
key,
|
||||
chunk_index as u32,
|
||||
chunk_data,
|
||||
)
|
||||
.await?;
|
||||
chunks.push(chunk_manifest);
|
||||
}
|
||||
|
||||
let md5_hex = format!("{:x}", Md5::digest(data));
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let manifest = ObjectManifest {
|
||||
bucket: bucket.to_string(),
|
||||
key: key.to_string(),
|
||||
version_id: uuid::Uuid::new_v4().to_string(),
|
||||
size: data.len() as u64,
|
||||
content_md5: md5_hex.clone(),
|
||||
content_type: metadata
|
||||
.get("content-type")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "binary/octet-stream".to_string()),
|
||||
metadata,
|
||||
created_at: now.clone(),
|
||||
last_modified: now,
|
||||
data_shards: self.erasure_config.data_shards,
|
||||
parity_shards: self.erasure_config.parity_shards,
|
||||
chunk_size: self.erasure_config.chunk_size_bytes,
|
||||
chunks,
|
||||
};
|
||||
|
||||
self.store_manifest(&manifest).await?;
|
||||
self.track_object_upsert(bucket, previous_size, manifest.size)
|
||||
.await;
|
||||
|
||||
Ok(PutResult { md5: md5_hex })
|
||||
}
|
||||
|
||||
pub async fn get_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1033,7 +1096,11 @@ impl DistributedStore {
|
||||
peers
|
||||
}
|
||||
|
||||
fn drive_health(&self, drive_states: &[DriveState], erasure_sets: &[ErasureSet]) -> Vec<ClusterDriveHealth> {
|
||||
fn drive_health(
|
||||
&self,
|
||||
drive_states: &[DriveState],
|
||||
erasure_sets: &[ErasureSet],
|
||||
) -> Vec<ClusterDriveHealth> {
|
||||
let local_node_id = self.state.local_node_id();
|
||||
let mut drive_to_set = HashMap::new();
|
||||
for erasure_set in erasure_sets {
|
||||
@@ -1118,7 +1185,10 @@ impl DistributedStore {
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
matches!(node_statuses.get(drive.node_id.as_str()), Some(NodeStatus::Online))
|
||||
matches!(
|
||||
node_statuses.get(drive.node_id.as_str()),
|
||||
Some(NodeStatus::Online)
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
|
||||
+5
-2
@@ -45,11 +45,14 @@ pub struct AuthConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Credential {
|
||||
#[serde(rename = "accessKeyId")]
|
||||
pub access_key_id: String,
|
||||
#[serde(rename = "secretAccessKey")]
|
||||
pub secret_access_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bucket_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+249
-18
@@ -7,6 +7,7 @@ use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use crate::config::Credential;
|
||||
use crate::config::SmartStorageConfig;
|
||||
use crate::server::StorageServer;
|
||||
use crate::storage::BucketExport;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IpcRequest {
|
||||
@@ -91,17 +92,15 @@ pub async fn management_loop() -> Result<()> {
|
||||
config: SmartStorageConfig,
|
||||
}
|
||||
match serde_json::from_value::<StartParams>(req.params) {
|
||||
Ok(params) => {
|
||||
match StorageServer::start(params.config).await {
|
||||
Ok(s) => {
|
||||
server = Some(s);
|
||||
send_response(id, serde_json::json!({}));
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(id, format!("Failed to start server: {}", e));
|
||||
}
|
||||
Ok(params) => match StorageServer::start(params.config).await {
|
||||
Ok(s) => {
|
||||
server = Some(s);
|
||||
send_response(id, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(id, format!("Failed to start server: {}", e));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
send_error(id, format!("Invalid start params: {}", e));
|
||||
}
|
||||
@@ -126,10 +125,7 @@ pub async fn management_loop() -> Result<()> {
|
||||
send_response(id, serde_json::json!({}));
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(
|
||||
id,
|
||||
format!("Failed to create bucket: {}", e),
|
||||
);
|
||||
send_error(id, format!("Failed to create bucket: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -141,6 +137,244 @@ pub async fn management_loop() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"createBucketTenant" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateBucketTenantParams {
|
||||
bucket_name: String,
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<CreateBucketTenantParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
let credential = Credential {
|
||||
access_key_id: params.access_key_id,
|
||||
secret_access_key: params.secret_access_key,
|
||||
bucket_name: Some(params.bucket_name.clone()),
|
||||
region: params.region,
|
||||
};
|
||||
match s
|
||||
.create_bucket_tenant(¶ms.bucket_name, credential)
|
||||
.await
|
||||
{
|
||||
Ok(credential) => match serde_json::to_value(credential) {
|
||||
Ok(value) => send_response(id, value),
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!("Failed to serialize bucket tenant: {}", error),
|
||||
),
|
||||
},
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!("Failed to create bucket tenant: {}", error),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
send_error(id, format!("Invalid createBucketTenant params: {}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
"deleteBucketTenant" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeleteBucketTenantParams {
|
||||
bucket_name: String,
|
||||
access_key_id: Option<String>,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<DeleteBucketTenantParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
match s
|
||||
.delete_bucket_tenant(
|
||||
¶ms.bucket_name,
|
||||
params.access_key_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => send_response(id, serde_json::json!({})),
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!("Failed to delete bucket tenant: {}", error),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
send_error(id, format!("Invalid deleteBucketTenant params: {}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
"rotateBucketTenantCredentials" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RotateBucketTenantCredentialsParams {
|
||||
bucket_name: String,
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<RotateBucketTenantCredentialsParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
let credential = Credential {
|
||||
access_key_id: params.access_key_id,
|
||||
secret_access_key: params.secret_access_key,
|
||||
bucket_name: Some(params.bucket_name.clone()),
|
||||
region: params.region,
|
||||
};
|
||||
match s
|
||||
.rotate_bucket_tenant_credentials(¶ms.bucket_name, credential)
|
||||
.await
|
||||
{
|
||||
Ok(credential) => match serde_json::to_value(credential) {
|
||||
Ok(value) => send_response(id, value),
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!("Failed to serialize bucket tenant: {}", error),
|
||||
),
|
||||
},
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!(
|
||||
"Failed to rotate bucket tenant credentials: {}",
|
||||
error
|
||||
),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
send_error(
|
||||
id,
|
||||
format!("Invalid rotateBucketTenantCredentials params: {}", error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"listBucketTenants" => {
|
||||
if let Some(ref s) = server {
|
||||
match serde_json::to_value(s.list_bucket_tenants().await) {
|
||||
Ok(value) => send_response(id, value),
|
||||
Err(error) => {
|
||||
send_error(id, format!("Failed to serialize bucket tenants: {}", error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
"getBucketTenantCredential" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GetBucketTenantCredentialParams {
|
||||
bucket_name: String,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<GetBucketTenantCredentialParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
match s.get_bucket_tenant_credential(¶ms.bucket_name).await {
|
||||
Some(credential) => match serde_json::to_value(credential) {
|
||||
Ok(value) => send_response(id, value),
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!("Failed to serialize bucket tenant: {}", error),
|
||||
),
|
||||
},
|
||||
None => send_error(
|
||||
id,
|
||||
format!(
|
||||
"No bucket tenant credential exists for bucket {}",
|
||||
params.bucket_name
|
||||
),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
send_error(
|
||||
id,
|
||||
format!("Invalid getBucketTenantCredential params: {}", error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"exportBucket" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExportBucketParams {
|
||||
bucket_name: String,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<ExportBucketParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
match s.store().export_bucket(¶ms.bucket_name).await {
|
||||
Ok(export) => match serde_json::to_value(export) {
|
||||
Ok(value) => send_response(id, value),
|
||||
Err(error) => send_error(
|
||||
id,
|
||||
format!("Failed to serialize bucket export: {}", error),
|
||||
),
|
||||
},
|
||||
Err(error) => {
|
||||
send_error(id, format!("Failed to export bucket: {}", error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
send_error(id, format!("Invalid exportBucket params: {}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
"importBucket" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImportBucketParams {
|
||||
bucket_name: String,
|
||||
source: BucketExport,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<ImportBucketParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
match s
|
||||
.store()
|
||||
.import_bucket(¶ms.bucket_name, params.source)
|
||||
.await
|
||||
{
|
||||
Ok(()) => send_response(id, serde_json::json!({})),
|
||||
Err(error) => {
|
||||
send_error(id, format!("Failed to import bucket: {}", error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
send_error(id, format!("Invalid importBucket params: {}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
"getStorageStats" => {
|
||||
if let Some(ref s) = server {
|
||||
match s.store().get_storage_stats().await {
|
||||
@@ -186,10 +420,7 @@ pub async fn management_loop() -> Result<()> {
|
||||
match serde_json::to_value(s.list_credentials().await) {
|
||||
Ok(value) => send_response(id, value),
|
||||
Err(error) => {
|
||||
send_error(
|
||||
id,
|
||||
format!("Failed to serialize credentials: {}", error),
|
||||
);
|
||||
send_error(id, format!("Failed to serialize credentials: {}", error));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
+255
-64
@@ -10,8 +10,8 @@ use hyper_util::rt::TokioIo;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -21,9 +21,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::action::{self, RequestContext, StorageAction};
|
||||
use crate::auth::{self, AuthenticatedIdentity};
|
||||
use crate::config::SmartStorageConfig;
|
||||
use crate::policy::{self, PolicyDecision, PolicyStore};
|
||||
use crate::error::StorageError;
|
||||
use crate::cluster::coordinator::DistributedStore;
|
||||
use crate::cluster::drive_manager::DriveManager;
|
||||
use crate::cluster::healing::HealingService;
|
||||
@@ -34,6 +31,9 @@ use crate::cluster::protocol::NodeInfo;
|
||||
use crate::cluster::quic_transport::QuicTransport;
|
||||
use crate::cluster::shard_store::ShardStore;
|
||||
use crate::cluster::state::ClusterState;
|
||||
use crate::config::{Credential, SmartStorageConfig};
|
||||
use crate::error::StorageError;
|
||||
use crate::policy::{self, PolicyDecision, PolicyStore};
|
||||
use crate::storage::{FileStore, StorageBackend};
|
||||
use crate::xml_response;
|
||||
|
||||
@@ -70,7 +70,6 @@ pub struct StorageServer {
|
||||
|
||||
impl StorageServer {
|
||||
pub async fn start(config: SmartStorageConfig) -> Result<Self> {
|
||||
let auth_runtime = Arc::new(auth::RuntimeCredentialStore::new(&config.auth));
|
||||
let mut cluster_shutdown_txs = Vec::new();
|
||||
let store: Arc<StorageBackend> = if let Some(ref cluster_config) = config.cluster {
|
||||
if cluster_config.enabled {
|
||||
@@ -88,8 +87,12 @@ impl StorageServer {
|
||||
let policy_store = Arc::new(PolicyStore::new(store.policies_dir()));
|
||||
policy_store.load_from_disk().await?;
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.address(), config.server.port)
|
||||
.parse()?;
|
||||
let auth_runtime = Arc::new(
|
||||
auth::RuntimeCredentialStore::new(&config.auth, Some(Self::credentials_path(&config)))
|
||||
.await?,
|
||||
);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.address(), config.server.port).parse()?;
|
||||
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
@@ -181,15 +184,81 @@ impl StorageServer {
|
||||
|
||||
pub async fn replace_credentials(
|
||||
&self,
|
||||
credentials: Vec<crate::config::Credential>,
|
||||
credentials: Vec<Credential>,
|
||||
) -> Result<(), StorageError> {
|
||||
self.auth_runtime.replace_credentials(credentials).await
|
||||
}
|
||||
|
||||
pub async fn create_bucket_tenant(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
credential: Credential,
|
||||
) -> Result<Credential> {
|
||||
self.ensure_tenant_auth_enabled()?;
|
||||
self.store.create_bucket(bucket_name).await?;
|
||||
Ok(self
|
||||
.auth_runtime
|
||||
.replace_bucket_tenant_credential(bucket_name, credential)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn rotate_bucket_tenant_credentials(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
credential: Credential,
|
||||
) -> Result<Credential> {
|
||||
self.ensure_tenant_auth_enabled()?;
|
||||
if !self.store.bucket_exists(bucket_name).await {
|
||||
return Err(StorageError::no_such_bucket().into());
|
||||
}
|
||||
Ok(self
|
||||
.auth_runtime
|
||||
.replace_bucket_tenant_credential(bucket_name, credential)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn delete_bucket_tenant(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
access_key_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
self.ensure_tenant_auth_enabled()?;
|
||||
self.auth_runtime
|
||||
.remove_bucket_tenant_credentials(bucket_name, access_key_id)
|
||||
.await?;
|
||||
if access_key_id.is_none() && self.store.bucket_exists(bucket_name).await {
|
||||
self.store.delete_bucket_recursive(bucket_name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_bucket_tenants(&self) -> Vec<crate::auth::BucketTenantMetadata> {
|
||||
self.auth_runtime.list_bucket_tenants().await
|
||||
}
|
||||
|
||||
pub async fn get_bucket_tenant_credential(&self, bucket_name: &str) -> Option<Credential> {
|
||||
self.auth_runtime
|
||||
.get_bucket_tenant_credential(bucket_name)
|
||||
.await
|
||||
}
|
||||
|
||||
fn ensure_tenant_auth_enabled(&self) -> Result<()> {
|
||||
if !self.auth_runtime.enabled() {
|
||||
anyhow::bail!("Bucket tenants require auth.enabled=true");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn credentials_path(config: &SmartStorageConfig) -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(&config.storage.directory)
|
||||
.join(".smartstorage")
|
||||
.join("credentials.json")
|
||||
}
|
||||
|
||||
async fn start_standalone(config: &SmartStorageConfig) -> Result<Arc<StorageBackend>> {
|
||||
let store = Arc::new(StorageBackend::Standalone(
|
||||
FileStore::new(config.storage.directory.clone().into()),
|
||||
));
|
||||
let store = Arc::new(StorageBackend::Standalone(FileStore::new(
|
||||
config.storage.directory.clone().into(),
|
||||
)));
|
||||
if config.storage.clean_slate {
|
||||
store.reset().await?;
|
||||
} else {
|
||||
@@ -208,7 +277,9 @@ impl StorageServer {
|
||||
let topology_path = persistence::topology_path(&cluster_metadata_dir);
|
||||
let persisted_identity = persistence::load_identity(&identity_path).await?;
|
||||
|
||||
if let (Some(configured_node_id), Some(identity)) = (&cluster_config.node_id, &persisted_identity) {
|
||||
if let (Some(configured_node_id), Some(identity)) =
|
||||
(&cluster_config.node_id, &persisted_identity)
|
||||
{
|
||||
if configured_node_id != &identity.node_id {
|
||||
anyhow::bail!(
|
||||
"Configured cluster node ID '{}' conflicts with persisted node ID '{}'",
|
||||
@@ -221,7 +292,11 @@ impl StorageServer {
|
||||
let node_id = cluster_config
|
||||
.node_id
|
||||
.clone()
|
||||
.or_else(|| persisted_identity.as_ref().map(|identity| identity.node_id.clone()))
|
||||
.or_else(|| {
|
||||
persisted_identity
|
||||
.as_ref()
|
||||
.map(|identity| identity.node_id.clone())
|
||||
})
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let cluster_id = persisted_identity
|
||||
.as_ref()
|
||||
@@ -273,7 +348,9 @@ impl StorageServer {
|
||||
let has_persisted_topology = persisted_topology.is_some();
|
||||
if let Some(topology) = persisted_topology {
|
||||
if topology.cluster_id != cluster_id {
|
||||
anyhow::bail!("Persisted topology cluster ID does not match persisted node identity");
|
||||
anyhow::bail!(
|
||||
"Persisted topology cluster ID does not match persisted node identity"
|
||||
);
|
||||
}
|
||||
cluster_state.apply_topology(&topology).await;
|
||||
} else if cluster_config.seed_nodes.is_empty() {
|
||||
@@ -347,7 +424,11 @@ impl StorageServer {
|
||||
let shard_stores_for_accept = local_shard_stores.clone();
|
||||
tokio::spawn(async move {
|
||||
transport_clone
|
||||
.accept_loop(shard_stores_for_accept, Some(cluster_state_for_accept), quic_shutdown_rx)
|
||||
.accept_loop(
|
||||
shard_stores_for_accept,
|
||||
Some(cluster_state_for_accept),
|
||||
quic_shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -400,7 +481,10 @@ impl StorageServer {
|
||||
);
|
||||
}
|
||||
|
||||
Ok((store, vec![quic_shutdown_tx, hb_shutdown_tx, heal_shutdown_tx]))
|
||||
Ok((
|
||||
store,
|
||||
vec![quic_shutdown_tx, hb_shutdown_tx, heal_shutdown_tx],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,17 +498,26 @@ impl SmartStorageConfig {
|
||||
// Request handling
|
||||
// ============================
|
||||
|
||||
type BoxBody = http_body_util::combinators::BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
|
||||
type BoxBody =
|
||||
http_body_util::combinators::BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
fn full_body(data: impl Into<Bytes>) -> BoxBody {
|
||||
http_body_util::Full::new(data.into())
|
||||
.map_err(|never: std::convert::Infallible| -> Box<dyn std::error::Error + Send + Sync> { match never {} })
|
||||
.map_err(
|
||||
|never: std::convert::Infallible| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
match never {}
|
||||
},
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn empty_body() -> BoxBody {
|
||||
http_body_util::Empty::new()
|
||||
.map_err(|never: std::convert::Infallible| -> Box<dyn std::error::Error + Send + Sync> { match never {} })
|
||||
.map_err(
|
||||
|never: std::convert::Infallible| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
match never {}
|
||||
},
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
@@ -445,10 +538,10 @@ impl Stream for FrameStream {
|
||||
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let inner = unsafe { self.map_unchecked_mut(|s| &mut s.inner) };
|
||||
match inner.poll_next(cx) {
|
||||
Poll::Ready(Some(Ok(bytes))) => {
|
||||
Poll::Ready(Some(Ok(hyper::body::Frame::data(bytes))))
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>))),
|
||||
Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(Ok(hyper::body::Frame::data(bytes)))),
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(
|
||||
Box::new(e) as Box<dyn std::error::Error + Send + Sync>
|
||||
))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
@@ -482,7 +575,11 @@ fn storage_error_response(err: &StorageError, request_id: &str) -> Response<BoxB
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn json_response(status: StatusCode, value: serde_json::Value, request_id: &str) -> Response<BoxBody> {
|
||||
fn json_response(
|
||||
status: StatusCode,
|
||||
value: serde_json::Value,
|
||||
request_id: &str,
|
||||
) -> Response<BoxBody> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
@@ -491,7 +588,12 @@ fn json_response(status: StatusCode, value: serde_json::Value, request_id: &str)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text_response(status: StatusCode, content_type: &str, body: String, request_id: &str) -> Response<BoxBody> {
|
||||
fn text_response(
|
||||
status: StatusCode,
|
||||
content_type: &str,
|
||||
body: String,
|
||||
request_id: &str,
|
||||
) -> Response<BoxBody> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", content_type)
|
||||
@@ -521,17 +623,20 @@ async fn handle_request(
|
||||
}
|
||||
|
||||
if method == Method::GET && uri.path().starts_with("/-/") {
|
||||
let resp = match handle_operational_request(uri.path(), store, &config, &metrics, &request_id).await {
|
||||
Ok(resp) => resp,
|
||||
Err(error) => {
|
||||
tracing::error!(error = %error, "Operational endpoint failed");
|
||||
json_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
serde_json::json!({ "ok": false, "error": error.to_string() }),
|
||||
&request_id,
|
||||
)
|
||||
}
|
||||
};
|
||||
let resp =
|
||||
match handle_operational_request(uri.path(), store, &config, &metrics, &request_id)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(error) => {
|
||||
tracing::error!(error = %error, "Operational endpoint failed");
|
||||
json_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
serde_json::json!({ "ok": false, "error": error.to_string() }),
|
||||
&request_id,
|
||||
)
|
||||
}
|
||||
};
|
||||
metrics.record_response(resp.status());
|
||||
return Ok(resp);
|
||||
}
|
||||
@@ -672,7 +777,11 @@ async fn handle_operational_request(
|
||||
let cluster_health = store.get_cluster_health().await?;
|
||||
let stats = store.get_storage_stats().await?;
|
||||
let cluster_enabled = if cluster_health.enabled { 1 } else { 0 };
|
||||
let quorum_healthy = if cluster_health.quorum_healthy.unwrap_or(true) { 1 } else { 0 };
|
||||
let quorum_healthy = if cluster_health.quorum_healthy.unwrap_or(true) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let body = format!(
|
||||
"# HELP smartstorage_requests_total Total HTTP requests observed by smartstorage.\n\
|
||||
# TYPE smartstorage_requests_total counter\n\
|
||||
@@ -720,6 +829,12 @@ async fn authorize_request(
|
||||
identity: Option<&AuthenticatedIdentity>,
|
||||
policy_store: &PolicyStore,
|
||||
) -> Result<(), StorageError> {
|
||||
if let Some(identity) = identity {
|
||||
if let Some(bucket_name) = identity.bucket_name.as_deref() {
|
||||
authorize_scoped_credential(ctx, bucket_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ListAllMyBuckets requires authentication (no bucket to apply policy to)
|
||||
if ctx.action == StorageAction::ListAllMyBuckets {
|
||||
if identity.is_none() {
|
||||
@@ -750,6 +865,46 @@ async fn authorize_request(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authorize_scoped_credential(
|
||||
ctx: &RequestContext,
|
||||
bucket_name: &str,
|
||||
) -> Result<(), StorageError> {
|
||||
let Some(request_bucket) = ctx.bucket.as_deref() else {
|
||||
return Err(StorageError::access_denied());
|
||||
};
|
||||
|
||||
if request_bucket != bucket_name {
|
||||
return Err(StorageError::access_denied());
|
||||
}
|
||||
|
||||
if let Some(source_bucket) = ctx.source_bucket.as_deref() {
|
||||
if source_bucket != bucket_name {
|
||||
return Err(StorageError::access_denied());
|
||||
}
|
||||
}
|
||||
|
||||
match ctx.action {
|
||||
StorageAction::CreateBucket
|
||||
| StorageAction::DeleteBucket
|
||||
| StorageAction::GetBucketPolicy
|
||||
| StorageAction::PutBucketPolicy
|
||||
| StorageAction::DeleteBucketPolicy
|
||||
| StorageAction::ListAllMyBuckets => Err(StorageError::access_denied()),
|
||||
StorageAction::HeadBucket
|
||||
| StorageAction::ListBucket
|
||||
| StorageAction::GetObject
|
||||
| StorageAction::HeadObject
|
||||
| StorageAction::PutObject
|
||||
| StorageAction::DeleteObject
|
||||
| StorageAction::CopyObject
|
||||
| StorageAction::ListBucketMultipartUploads
|
||||
| StorageAction::AbortMultipartUpload
|
||||
| StorageAction::InitiateMultipartUpload
|
||||
| StorageAction::UploadPart
|
||||
| StorageAction::CompleteMultipartUpload => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Routing
|
||||
// ============================
|
||||
@@ -788,9 +943,16 @@ async fn route_request(
|
||||
// Check for ?policy query parameter
|
||||
if query.contains_key("policy") {
|
||||
return match method {
|
||||
Method::GET => handle_get_bucket_policy(policy_store, &bucket, request_id).await,
|
||||
Method::PUT => handle_put_bucket_policy(req, &store, policy_store, &bucket, request_id).await,
|
||||
Method::DELETE => handle_delete_bucket_policy(policy_store, &bucket, request_id).await,
|
||||
Method::GET => {
|
||||
handle_get_bucket_policy(policy_store, &bucket, request_id).await
|
||||
}
|
||||
Method::PUT => {
|
||||
handle_put_bucket_policy(req, &store, policy_store, &bucket, request_id)
|
||||
.await
|
||||
}
|
||||
Method::DELETE => {
|
||||
handle_delete_bucket_policy(policy_store, &bucket, request_id).await
|
||||
}
|
||||
_ => Ok(empty_response(StatusCode::METHOD_NOT_ALLOWED, request_id)),
|
||||
};
|
||||
}
|
||||
@@ -804,7 +966,9 @@ async fn route_request(
|
||||
}
|
||||
}
|
||||
Method::PUT => handle_create_bucket(store, &bucket, request_id).await,
|
||||
Method::DELETE => handle_delete_bucket(store, &bucket, request_id, policy_store).await,
|
||||
Method::DELETE => {
|
||||
handle_delete_bucket(store, &bucket, request_id, policy_store).await
|
||||
}
|
||||
Method::HEAD => handle_head_bucket(store, &bucket, request_id).await,
|
||||
_ => Ok(empty_response(StatusCode::METHOD_NOT_ALLOWED, request_id)),
|
||||
}
|
||||
@@ -824,12 +988,8 @@ async fn route_request(
|
||||
handle_put_object(req, store, &bucket, &key, request_id).await
|
||||
}
|
||||
}
|
||||
Method::GET => {
|
||||
handle_get_object(req, store, &bucket, &key, request_id).await
|
||||
}
|
||||
Method::HEAD => {
|
||||
handle_head_object(store, &bucket, &key, request_id).await
|
||||
}
|
||||
Method::GET => handle_get_object(req, store, &bucket, &key, request_id).await,
|
||||
Method::HEAD => handle_head_object(store, &bucket, &key, request_id).await,
|
||||
Method::DELETE => {
|
||||
if query.contains_key("uploadId") {
|
||||
let upload_id = query.get("uploadId").unwrap();
|
||||
@@ -843,7 +1003,8 @@ async fn route_request(
|
||||
handle_initiate_multipart(req, store, &bucket, &key, request_id).await
|
||||
} else if query.contains_key("uploadId") {
|
||||
let upload_id = query.get("uploadId").unwrap().clone();
|
||||
handle_complete_multipart(req, store, &bucket, &key, &upload_id, request_id).await
|
||||
handle_complete_multipart(req, store, &bucket, &key, &upload_id, request_id)
|
||||
.await
|
||||
} else {
|
||||
let err = StorageError::invalid_request("Invalid POST request");
|
||||
Ok(storage_error_response(&err, request_id))
|
||||
@@ -972,7 +1133,13 @@ async fn handle_get_object(
|
||||
|
||||
let mut builder = Response::builder()
|
||||
.header("ETag", format!("\"{}\"", result.md5))
|
||||
.header("Last-Modified", result.last_modified.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
|
||||
.header(
|
||||
"Last-Modified",
|
||||
result
|
||||
.last_modified
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string(),
|
||||
)
|
||||
.header("Content-Type", &content_type)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("x-amz-request-id", request_id);
|
||||
@@ -1023,7 +1190,13 @@ async fn handle_head_object(
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("ETag", format!("\"{}\"", result.md5))
|
||||
.header("Last-Modified", result.last_modified.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
|
||||
.header(
|
||||
"Last-Modified",
|
||||
result
|
||||
.last_modified
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string(),
|
||||
)
|
||||
.header("Content-Type", &content_type)
|
||||
.header("Content-Length", result.size.to_string())
|
||||
.header("Accept-Ranges", "bytes")
|
||||
@@ -1086,7 +1259,14 @@ async fn handle_copy_object(
|
||||
};
|
||||
|
||||
let result = store
|
||||
.copy_object(&src_bucket, &src_key, dest_bucket, dest_key, &metadata_directive, new_metadata)
|
||||
.copy_object(
|
||||
&src_bucket,
|
||||
&src_key,
|
||||
dest_bucket,
|
||||
dest_key,
|
||||
&metadata_directive,
|
||||
new_metadata,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let xml = xml_response::copy_object_result_xml(&result.md5, &result.last_modified.to_rfc3339());
|
||||
@@ -1130,7 +1310,11 @@ async fn handle_put_bucket_policy(
|
||||
}
|
||||
|
||||
// Read body
|
||||
let body_bytes = req.collect().await.map_err(|e| anyhow::anyhow!("Body error: {}", e))?.to_bytes();
|
||||
let body_bytes = req
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Body error: {}", e))?
|
||||
.to_bytes();
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
// Validate and parse
|
||||
@@ -1212,7 +1396,11 @@ async fn handle_complete_multipart(
|
||||
request_id: &str,
|
||||
) -> Result<Response<BoxBody>> {
|
||||
// Read request body (XML)
|
||||
let body_bytes = req.collect().await.map_err(|e| anyhow::anyhow!("Body error: {}", e))?.to_bytes();
|
||||
let body_bytes = req
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Body error: {}", e))?
|
||||
.to_bytes();
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
// Parse parts from XML using regex-like approach
|
||||
@@ -1276,8 +1464,12 @@ fn extract_metadata(headers: &hyper::HeaderMap) -> HashMap<String, String> {
|
||||
let name_str = name.as_str().to_lowercase();
|
||||
if let Ok(val) = value.to_str() {
|
||||
match name_str.as_str() {
|
||||
"content-type" | "cache-control" | "content-disposition"
|
||||
| "content-encoding" | "content-language" | "expires" => {
|
||||
"content-type"
|
||||
| "cache-control"
|
||||
| "content-disposition"
|
||||
| "content-encoding"
|
||||
| "content-language"
|
||||
| "expires" => {
|
||||
metadata.insert(name_str, val.to_string());
|
||||
}
|
||||
_ if name_str.starts_with("x-amz-meta-") => {
|
||||
@@ -1290,7 +1482,10 @@ fn extract_metadata(headers: &hyper::HeaderMap) -> HashMap<String, String> {
|
||||
|
||||
// Default content-type
|
||||
if !metadata.contains_key("content-type") {
|
||||
metadata.insert("content-type".to_string(), "binary/octet-stream".to_string());
|
||||
metadata.insert(
|
||||
"content-type".to_string(),
|
||||
"binary/octet-stream".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
metadata
|
||||
@@ -1325,10 +1520,9 @@ fn parse_complete_multipart_xml(xml: &str) -> Vec<(u32, String)> {
|
||||
if let Some(part_end) = after_part.find("</Part>") {
|
||||
let part_content = &after_part[..part_end];
|
||||
|
||||
let part_number = extract_xml_value(part_content, "PartNumber")
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
let etag = extract_xml_value(part_content, "ETag")
|
||||
.map(|s| s.replace('"', ""));
|
||||
let part_number =
|
||||
extract_xml_value(part_content, "PartNumber").and_then(|s| s.parse::<u32>().ok());
|
||||
let etag = extract_xml_value(part_content, "ETag").map(|s| s.replace('"', ""));
|
||||
|
||||
if let (Some(pn), Some(et)) = (part_number, etag) {
|
||||
parts.push((pn, et));
|
||||
@@ -1394,9 +1588,6 @@ fn add_cors_headers(headers: &mut hyper::HeaderMap, config: &SmartStorageConfig)
|
||||
);
|
||||
}
|
||||
if config.cors.allow_credentials == Some(true) {
|
||||
headers.insert(
|
||||
"access-control-allow-credentials",
|
||||
"true".parse().unwrap(),
|
||||
);
|
||||
headers.insert("access-control-allow-credentials", "true".parse().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,25 @@ pub struct StorageStats {
|
||||
pub storage_locations: Vec<StorageLocationSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BucketExport {
|
||||
pub format: String,
|
||||
pub bucket_name: String,
|
||||
pub exported_at: i64,
|
||||
pub objects: Vec<BucketExportObject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BucketExportObject {
|
||||
pub key: String,
|
||||
pub size: u64,
|
||||
pub md5: String,
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub data_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClusterPeerHealth {
|
||||
@@ -593,6 +612,40 @@ impl FileStore {
|
||||
Ok(PutResult { md5: md5_hex })
|
||||
}
|
||||
|
||||
pub async fn put_object_bytes(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
metadata: HashMap<String, String>,
|
||||
) -> Result<PutResult> {
|
||||
if !self.bucket_exists(bucket).await {
|
||||
return Err(StorageError::no_such_bucket().into());
|
||||
}
|
||||
|
||||
let previous_size = self.object_size_if_exists(bucket, key).await;
|
||||
let object_path = self.object_path(bucket, key);
|
||||
if let Some(parent) = object_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
fs::write(&object_path, data).await?;
|
||||
let md5_hex = format!("{:x}", Md5::digest(data));
|
||||
fs::write(format!("{}.md5", object_path.display()), &md5_hex).await?;
|
||||
|
||||
let metadata_json = serde_json::to_string_pretty(&metadata)?;
|
||||
fs::write(
|
||||
format!("{}.metadata.json", object_path.display()),
|
||||
metadata_json,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.track_object_upsert(bucket, previous_size, data.len() as u64)
|
||||
.await;
|
||||
|
||||
Ok(PutResult { md5: md5_hex })
|
||||
}
|
||||
|
||||
pub async fn get_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1311,6 +1364,25 @@ impl StorageBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_bucket_recursive(&self, bucket: &str) -> Result<()> {
|
||||
if !self.bucket_exists(bucket).await {
|
||||
return Err(StorageError::no_such_bucket().into());
|
||||
}
|
||||
|
||||
loop {
|
||||
let objects = self.list_objects(bucket, "", "", 1000, None).await?;
|
||||
if objects.contents.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for object in objects.contents {
|
||||
self.delete_object(bucket, &object.key).await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.delete_bucket(bucket).await
|
||||
}
|
||||
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1324,6 +1396,21 @@ impl StorageBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_object_bytes(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
metadata: HashMap<String, String>,
|
||||
) -> Result<PutResult> {
|
||||
match self {
|
||||
StorageBackend::Standalone(fs) => {
|
||||
fs.put_object_bytes(bucket, key, data, metadata).await
|
||||
}
|
||||
StorageBackend::Clustered(ds) => ds.put_object_bytes(bucket, key, data, metadata).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1453,6 +1540,55 @@ impl StorageBackend {
|
||||
StorageBackend::Clustered(ds) => ds.list_multipart_uploads(bucket).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_bucket(&self, bucket: &str) -> Result<BucketExport> {
|
||||
if !self.bucket_exists(bucket).await {
|
||||
return Err(StorageError::no_such_bucket().into());
|
||||
}
|
||||
|
||||
let objects = self.list_objects(bucket, "", "", usize::MAX, None).await?;
|
||||
let mut exported_objects = Vec::with_capacity(objects.contents.len());
|
||||
|
||||
for object in objects.contents {
|
||||
let result = self.get_object(bucket, &object.key, None).await?;
|
||||
let mut file = result.body;
|
||||
let mut data = Vec::with_capacity(result.size as usize);
|
||||
file.read_to_end(&mut data).await?;
|
||||
exported_objects.push(BucketExportObject {
|
||||
key: object.key,
|
||||
size: result.size,
|
||||
md5: result.md5,
|
||||
metadata: result.metadata,
|
||||
data_hex: hex::encode(data),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BucketExport {
|
||||
format: "smartstorage.bucket.v1".to_string(),
|
||||
bucket_name: bucket.to_string(),
|
||||
exported_at: Utc::now().timestamp_millis(),
|
||||
objects: exported_objects,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn import_bucket(&self, bucket: &str, source: BucketExport) -> Result<()> {
|
||||
if source.format != "smartstorage.bucket.v1" {
|
||||
return Err(StorageError::invalid_request("Unsupported bucket export format.").into());
|
||||
}
|
||||
|
||||
if !self.bucket_exists(bucket).await {
|
||||
self.create_bucket(bucket).await?;
|
||||
}
|
||||
|
||||
for object in source.objects {
|
||||
let data = hex::decode(&object.data_hex)
|
||||
.map_err(|error| StorageError::invalid_request(&error.to_string()))?;
|
||||
self.put_object_bytes(bucket, &object.key, &data, object.metadata)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
|
||||
Reference in New Issue
Block a user