feat(bucket-tenants): add persisted bucket-scoped tenant credentials with bucket export and import APIs

This commit is contained in:
2026-05-02 11:14:15 +00:00
parent 53d663597a
commit 7f2546e041
14 changed files with 1675 additions and 117 deletions
+17
View File
@@ -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
View File
@@ -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();
+78 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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(&params.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(
&params.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(&params.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(&params.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(&params.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(&params.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
View File
@@ -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());
}
}
+136
View File
@@ -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(())
}
}
// ============================