feat(bucket-tenants): add persisted bucket-scoped tenant credentials with bucket export and import APIs
This commit is contained in:
+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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user