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
+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());
}
}