mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-06 13:27:43 +00:00
refactor(app): add application layer module entry (#1907)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Application layer module entry.
|
||||
//! Concrete use-case modules will be introduced incrementally in Phase 3.
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
mod admin;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod error;
|
||||
|
||||
@@ -228,7 +228,6 @@ where
|
||||
|
||||
if ConditionalCorsLayer::is_s3_path(&path)
|
||||
&& !bucket.is_empty()
|
||||
&& cors_origins.is_some()
|
||||
&& let Some(cors_headers) = apply_cors_headers(&bucket, &method_clone, &request_headers_clone).await
|
||||
{
|
||||
for (key, value) in cors_headers.iter() {
|
||||
@@ -246,6 +245,10 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
if method == Method::OPTIONS && ConditionalCorsLayer::is_s3_path(&path) {
|
||||
return Box::pin(async move { Ok(Response::builder().status(StatusCode::OK).body(ResBody::default()).unwrap()) });
|
||||
}
|
||||
|
||||
let mut inner = self.inner.clone();
|
||||
Box::pin(async move {
|
||||
let mut response = inner.call(req).await.map_err(Into::into)?;
|
||||
|
||||
+246
-15
@@ -13,10 +13,21 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::ecfs::FS;
|
||||
use super::ecfs::{
|
||||
ACL_GROUP_ALL_USERS, ACL_GROUP_AUTHENTICATED_USERS, INTERNAL_ACL_METADATA_KEY, StoredAcl, default_owner,
|
||||
parse_acl_json_or_canned_bucket, parse_acl_json_or_canned_object, stored_acl_from_canned_bucket,
|
||||
stored_acl_from_canned_object,
|
||||
};
|
||||
use super::options::get_opts;
|
||||
use crate::auth::{check_key_valid, get_condition_values, get_session_token};
|
||||
use crate::error::ApiError;
|
||||
use crate::license::license_check;
|
||||
use crate::server::RemoteAddr;
|
||||
use rustfs_ecstore::StorageAPI;
|
||||
use rustfs_ecstore::bucket::metadata_sys;
|
||||
use rustfs_ecstore::bucket::policy_sys::PolicySys;
|
||||
use rustfs_ecstore::error::StorageError;
|
||||
use rustfs_ecstore::new_object_layer_fn;
|
||||
use rustfs_iam::error::Error as IamError;
|
||||
use rustfs_policy::policy::action::{Action, S3Action};
|
||||
use rustfs_policy::policy::{Args, BucketPolicyArgs};
|
||||
@@ -36,11 +47,164 @@ pub(crate) struct ReqInfo {
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AclTarget {
|
||||
Bucket,
|
||||
Object,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AclPermission {
|
||||
Read,
|
||||
Write,
|
||||
ReadAcp,
|
||||
WriteAcp,
|
||||
}
|
||||
|
||||
fn acl_permission_for_action(action: &Action) -> Option<(AclTarget, AclPermission)> {
|
||||
match action {
|
||||
Action::S3Action(S3Action::ListBucketAction) => Some((AclTarget::Bucket, AclPermission::Read)),
|
||||
Action::S3Action(S3Action::PutObjectAction) => Some((AclTarget::Bucket, AclPermission::Write)),
|
||||
Action::S3Action(S3Action::GetBucketAclAction) => Some((AclTarget::Bucket, AclPermission::ReadAcp)),
|
||||
Action::S3Action(S3Action::PutBucketAclAction) => Some((AclTarget::Bucket, AclPermission::WriteAcp)),
|
||||
Action::S3Action(S3Action::GetObjectAction) => Some((AclTarget::Object, AclPermission::Read)),
|
||||
Action::S3Action(S3Action::GetObjectAclAction) => Some((AclTarget::Object, AclPermission::ReadAcp)),
|
||||
Action::S3Action(S3Action::PutObjectAclAction) => Some((AclTarget::Object, AclPermission::WriteAcp)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_matches(grant_perm: &str, required: AclPermission) -> bool {
|
||||
if grant_perm == Permission::FULL_CONTROL {
|
||||
return true;
|
||||
}
|
||||
|
||||
match required {
|
||||
AclPermission::Read => grant_perm == Permission::READ,
|
||||
AclPermission::Write => grant_perm == Permission::WRITE,
|
||||
AclPermission::ReadAcp => grant_perm == Permission::READ_ACP,
|
||||
AclPermission::WriteAcp => grant_perm == Permission::WRITE_ACP,
|
||||
}
|
||||
}
|
||||
|
||||
fn acl_allows(
|
||||
acl: &StoredAcl,
|
||||
user_id: Option<&str>,
|
||||
is_authenticated: bool,
|
||||
required: AclPermission,
|
||||
ignore_public_acls: bool,
|
||||
) -> bool {
|
||||
for grant in &acl.grants {
|
||||
if !permission_matches(grant.permission.as_str(), required) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match grant.grantee.grantee_type.as_str() {
|
||||
"CanonicalUser" => {
|
||||
if user_id.is_some_and(|id| grant.grantee.id.as_deref() == Some(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Group" => {
|
||||
if ignore_public_acls {
|
||||
continue;
|
||||
}
|
||||
if let Some(uri) = grant.grantee.uri.as_deref() {
|
||||
if uri == ACL_GROUP_ALL_USERS {
|
||||
return true;
|
||||
}
|
||||
if uri == ACL_GROUP_AUTHENTICATED_USERS && is_authenticated {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
async fn load_bucket_acl(bucket: &str) -> S3Result<StoredAcl> {
|
||||
let owner = default_owner();
|
||||
match metadata_sys::get_bucket_acl_config(bucket).await {
|
||||
Ok((acl, _)) => Ok(parse_acl_json_or_canned_bucket(&acl, &owner)),
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
Ok(stored_acl_from_canned_bucket(BucketCannedACL::PRIVATE, &owner))
|
||||
} else {
|
||||
Err(S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_object_acl(bucket: &str, object: &str, version_id: Option<&str>, headers: &http::HeaderMap) -> S3Result<StoredAcl> {
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let opts = get_opts(bucket, object, version_id.map(|v| v.to_string()), None, headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
let info = store.get_object_info(bucket, object, &opts).await.map_err(ApiError::from)?;
|
||||
|
||||
let bucket_owner = default_owner();
|
||||
let object_owner = info
|
||||
.user_defined
|
||||
.get(INTERNAL_ACL_METADATA_KEY)
|
||||
.and_then(|acl| serde_json::from_str::<StoredAcl>(acl).ok())
|
||||
.map(|acl| acl.owner)
|
||||
.unwrap_or_else(default_owner);
|
||||
|
||||
Ok(info
|
||||
.user_defined
|
||||
.get(INTERNAL_ACL_METADATA_KEY)
|
||||
.map(|acl| parse_acl_json_or_canned_object(acl, &bucket_owner, &object_owner))
|
||||
.unwrap_or_else(|| stored_acl_from_canned_object(ObjectCannedACL::PRIVATE, &bucket_owner, &object_owner)))
|
||||
}
|
||||
|
||||
async fn check_acl_access<T>(req: &S3Request<T>, req_info: &ReqInfo, action: &Action, policy_allowed: bool) -> S3Result<bool> {
|
||||
if req_info.is_owner || policy_allowed {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let Some((target, permission)) = acl_permission_for_action(action) else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
let bucket = req_info.bucket.as_deref().unwrap_or("");
|
||||
if bucket.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let ignore_public_acls = match metadata_sys::get_public_access_block_config(bucket).await {
|
||||
Ok((config, _)) => config.ignore_public_acls.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
let user_id = req_info.cred.as_ref().map(|cred| cred.access_key.as_str());
|
||||
let is_authenticated = user_id.is_some();
|
||||
|
||||
let acl = match target {
|
||||
AclTarget::Bucket => load_bucket_acl(bucket).await?,
|
||||
AclTarget::Object => {
|
||||
let object = req_info.object.as_deref().unwrap_or("");
|
||||
if object.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
load_object_acl(bucket, object, req_info.version_id.as_deref(), &req.headers).await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(acl_allows(&acl, user_id, is_authenticated, permission, ignore_public_acls))
|
||||
}
|
||||
|
||||
/// Authorizes the request based on the action and credentials.
|
||||
pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3Result<()> {
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
let req_info = req.extensions.get::<ReqInfo>().expect("ReqInfo not found");
|
||||
|
||||
if let Some(cred) = &req_info.cred {
|
||||
let Ok(iam_store) = rustfs_iam::get() else {
|
||||
@@ -53,6 +217,23 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
let default_claims = HashMap::new();
|
||||
let claims = cred.claims.as_ref().unwrap_or(&default_claims);
|
||||
let conditions = get_condition_values(&req.headers, cred, req_info.version_id.as_deref(), None, remote_addr);
|
||||
let bucket_name = req_info.bucket.as_deref().unwrap_or("");
|
||||
|
||||
if !bucket_name.is_empty()
|
||||
&& !PolicySys::is_allowed(&BucketPolicyArgs {
|
||||
bucket: bucket_name,
|
||||
action,
|
||||
// Run this early check in deny-only mode so ACL/IAM fallbacks can still grant access.
|
||||
is_owner: true,
|
||||
account: &cred.access_key,
|
||||
groups: &cred.groups,
|
||||
conditions: &conditions,
|
||||
object: req_info.object.as_deref().unwrap_or(""),
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Err(s3_error!(AccessDenied, "Access Denied"));
|
||||
}
|
||||
|
||||
if action == Action::S3Action(S3Action::DeleteObjectAction)
|
||||
&& req_info.version_id.is_some()
|
||||
@@ -83,7 +264,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
return Err(s3_error!(AccessDenied, "Access Denied"));
|
||||
}
|
||||
|
||||
if iam_store
|
||||
let iam_allowed = iam_store
|
||||
.is_allowed(&Args {
|
||||
account: &cred.access_key,
|
||||
groups: &cred.groups,
|
||||
@@ -95,9 +276,29 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
claims,
|
||||
deny_only: false,
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Ok(());
|
||||
.await;
|
||||
|
||||
if iam_allowed {
|
||||
let policy_allowed = if !bucket_name.is_empty() {
|
||||
PolicySys::is_allowed(&BucketPolicyArgs {
|
||||
bucket: bucket_name,
|
||||
action,
|
||||
is_owner: false,
|
||||
account: &cred.access_key,
|
||||
groups: &cred.groups,
|
||||
conditions: &conditions,
|
||||
object: req_info.object.as_deref().unwrap_or(""),
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if check_acl_access(req, req_info, &action, policy_allowed).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(s3_error!(AccessDenied, "Access Denied"));
|
||||
}
|
||||
|
||||
if PolicySys::is_allowed(&BucketPolicyArgs {
|
||||
@@ -154,6 +355,23 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
req.region.as_deref(),
|
||||
remote_addr,
|
||||
);
|
||||
let bucket_name = req_info.bucket.as_deref().unwrap_or("");
|
||||
|
||||
if !bucket_name.is_empty()
|
||||
&& !PolicySys::is_allowed(&BucketPolicyArgs {
|
||||
bucket: bucket_name,
|
||||
action,
|
||||
// Run this early check in deny-only mode so ACL checks are not bypassed.
|
||||
is_owner: true,
|
||||
account: "",
|
||||
groups: &None,
|
||||
conditions: &conditions,
|
||||
object: req_info.object.as_deref().unwrap_or(""),
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Err(s3_error!(AccessDenied, "Access Denied"));
|
||||
}
|
||||
|
||||
if action != Action::S3Action(S3Action::ListAllMyBucketsAction) {
|
||||
if PolicySys::is_allowed(&BucketPolicyArgs {
|
||||
@@ -184,6 +402,10 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if acl_permission_for_action(&action).is_some() && check_acl_access(req, req_info, &action, false).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,8 +724,11 @@ impl S3Access for FS {
|
||||
/// Checks whether the DeletePublicAccessBlock request has accesses to the resources.
|
||||
///
|
||||
/// This method returns `Ok(())` by default.
|
||||
async fn delete_public_access_block(&self, _req: &mut S3Request<DeletePublicAccessBlockInput>) -> S3Result<()> {
|
||||
Ok(())
|
||||
async fn delete_public_access_block(&self, req: &mut S3Request<DeletePublicAccessBlockInput>) -> S3Result<()> {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::DeleteBucketPublicAccessBlockAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the GetBucketAccelerateConfiguration request has accesses to the resources.
|
||||
@@ -523,7 +748,7 @@ impl S3Access for FS {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
|
||||
authorize_request(req, Action::S3Action(S3Action::GetBucketAclAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the GetBucketAnalyticsConfiguration request has accesses to the resources.
|
||||
@@ -640,7 +865,7 @@ impl S3Access for FS {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
|
||||
authorize_request(req, Action::S3Action(S3Action::GetObjectAclAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the GetBucketPolicyStatus request has accesses to the resources.
|
||||
@@ -797,8 +1022,11 @@ impl S3Access for FS {
|
||||
/// Checks whether the GetPublicAccessBlock request has accesses to the resources.
|
||||
///
|
||||
/// This method returns `Ok(())` by default.
|
||||
async fn get_public_access_block(&self, _req: &mut S3Request<GetPublicAccessBlockInput>) -> S3Result<()> {
|
||||
Ok(())
|
||||
async fn get_public_access_block(&self, req: &mut S3Request<GetPublicAccessBlockInput>) -> S3Result<()> {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::GetBucketPublicAccessBlockAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the HeadBucket request has accesses to the resources.
|
||||
@@ -935,7 +1163,7 @@ impl S3Access for FS {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
|
||||
authorize_request(req, Action::S3Action(S3Action::PutBucketAclAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the PutBucketAnalyticsConfiguration request has accesses to the resources.
|
||||
@@ -1042,7 +1270,7 @@ impl S3Access for FS {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
|
||||
authorize_request(req, Action::S3Action(S3Action::PutObjectAclAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the PutBucketReplication request has accesses to the resources.
|
||||
@@ -1169,8 +1397,11 @@ impl S3Access for FS {
|
||||
/// Checks whether the PutPublicAccessBlock request has accesses to the resources.
|
||||
///
|
||||
/// This method returns `Ok(())` by default.
|
||||
async fn put_public_access_block(&self, _req: &mut S3Request<PutPublicAccessBlockInput>) -> S3Result<()> {
|
||||
Ok(())
|
||||
async fn put_public_access_block(&self, req: &mut S3Request<PutPublicAccessBlockInput>) -> S3Result<()> {
|
||||
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::PutBucketPublicAccessBlockAction)).await
|
||||
}
|
||||
|
||||
/// Checks whether the RestoreObject request has accesses to the resources.
|
||||
|
||||
+2314
-614
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ use crate::config::workload_profiles::{
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::server::cors;
|
||||
use crate::storage::ecfs::ListObjectUnorderedQuery;
|
||||
use crate::storage::ecfs::{InMemoryAsyncReader, ListObjectUnorderedQuery};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use metrics::counter;
|
||||
use rustfs_ecstore::bucket::metadata_sys;
|
||||
@@ -27,6 +27,9 @@ use rustfs_ecstore::bucket::replication::ReplicationConfigurationExt;
|
||||
use rustfs_ecstore::error::StorageError;
|
||||
use rustfs_ecstore::store_api::{BucketOptions, ObjectInfo, ObjectToDelete};
|
||||
use rustfs_ecstore::{StorageAPI, new_object_layer_fn};
|
||||
use rustfs_filemeta::ObjectPartInfo;
|
||||
use rustfs_kms::{EncryptionMetadata, ObjectEncryptionContext, get_global_encryption_service};
|
||||
use rustfs_rio::{DecryptReader, Reader, WarpReader};
|
||||
use rustfs_targets::EventName;
|
||||
use rustfs_targets::arn::{TargetID, TargetIDError};
|
||||
use rustfs_utils::http::{
|
||||
@@ -36,7 +39,7 @@ use rustfs_utils::http::{
|
||||
use s3s::dto::{
|
||||
Delimiter, LambdaFunctionConfiguration, NotificationConfigurationFilter, ObjectLockConfiguration, ObjectLockEnabled,
|
||||
ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode, QueueConfiguration,
|
||||
TopicConfiguration,
|
||||
ServerSideEncryption, TopicConfiguration,
|
||||
};
|
||||
use s3s::{S3Error, S3ErrorCode, S3Response, S3Result};
|
||||
use serde_urlencoded::from_bytes;
|
||||
@@ -46,6 +49,7 @@ use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{format_description::FormatItem, macros::format_description};
|
||||
use tokio::io::AsyncRead;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
pub const RFC1123: &[FormatItem<'_>] =
|
||||
@@ -63,6 +67,7 @@ pub const RFC1123: &[FormatItem<'_>] =
|
||||
/// # Arguments
|
||||
/// * `object_lock_config` - Optional bucket Object Lock configuration. If None, no retention is applied.
|
||||
/// * `metadata` - Mutable reference to object metadata HashMap. Retention headers are inserted here.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn apply_lock_retention(object_lock_config: Option<ObjectLockConfiguration>, metadata: &mut HashMap<String, String>) {
|
||||
if metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER) || metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) {
|
||||
return;
|
||||
@@ -187,6 +192,168 @@ pub(crate) fn get_buffer_size_opt_in(file_size: i64) -> usize {
|
||||
buffer_size
|
||||
}
|
||||
|
||||
pub(crate) async fn create_managed_encryption_material(
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
algorithm: &ServerSideEncryption,
|
||||
kms_key_id: Option<String>,
|
||||
original_size: i64,
|
||||
) -> Result<crate::storage::ecfs::ManagedEncryptionMaterial, ApiError> {
|
||||
let Some(service) = get_global_encryption_service().await else {
|
||||
return Err(ApiError::from(StorageError::other("KMS encryption service is not initialized")));
|
||||
};
|
||||
|
||||
if !is_managed_sse(algorithm) {
|
||||
return Err(ApiError::from(StorageError::other(format!(
|
||||
"Unsupported server-side encryption algorithm: {}",
|
||||
algorithm.as_str()
|
||||
))));
|
||||
}
|
||||
|
||||
let algorithm_str = algorithm.as_str();
|
||||
|
||||
let mut context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
||||
if original_size >= 0 {
|
||||
context = context.with_size(original_size as u64);
|
||||
}
|
||||
|
||||
let mut kms_key_candidate = kms_key_id;
|
||||
if kms_key_candidate.is_none() {
|
||||
kms_key_candidate = service.get_default_key_id().cloned();
|
||||
}
|
||||
|
||||
let kms_key_to_use = kms_key_candidate
|
||||
.clone()
|
||||
.ok_or_else(|| ApiError::from(StorageError::other("No KMS key available for managed server-side encryption")))?;
|
||||
|
||||
let (data_key, encrypted_data_key) = service
|
||||
.create_data_key(&kms_key_candidate, &context)
|
||||
.await
|
||||
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to create data key: {e}"))))?;
|
||||
|
||||
let metadata = EncryptionMetadata {
|
||||
algorithm: algorithm_str.to_string(),
|
||||
key_id: kms_key_to_use.clone(),
|
||||
key_version: 1,
|
||||
iv: data_key.nonce.to_vec(),
|
||||
tag: None,
|
||||
encryption_context: context.encryption_context.clone(),
|
||||
encrypted_at: jiff::Zoned::now(),
|
||||
original_size: if original_size >= 0 { original_size as u64 } else { 0 },
|
||||
encrypted_data_key,
|
||||
};
|
||||
|
||||
let mut headers = service.metadata_to_headers(&metadata);
|
||||
headers.insert("x-rustfs-encryption-original-size".to_string(), metadata.original_size.to_string());
|
||||
|
||||
Ok(crate::storage::ecfs::ManagedEncryptionMaterial {
|
||||
data_key,
|
||||
headers,
|
||||
kms_key_id: kms_key_to_use,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_managed_encryption_key(
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
metadata: &HashMap<String, String>,
|
||||
) -> Result<Option<([u8; 32], [u8; 12], Option<i64>)>, ApiError> {
|
||||
if !metadata.contains_key("x-rustfs-encryption-key") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(service) = get_global_encryption_service().await else {
|
||||
return Err(ApiError::from(StorageError::other("KMS encryption service is not initialized")));
|
||||
};
|
||||
|
||||
let parsed = service
|
||||
.headers_to_metadata(metadata)
|
||||
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to parse encryption metadata: {e}"))))?;
|
||||
|
||||
if parsed.iv.len() != 12 {
|
||||
return Err(ApiError::from(StorageError::other("Invalid encryption nonce length; expected 12 bytes")));
|
||||
}
|
||||
|
||||
let context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
||||
let data_key = service
|
||||
.decrypt_data_key(&parsed.encrypted_data_key, &context)
|
||||
.await
|
||||
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decrypt data key: {e}"))))?;
|
||||
|
||||
let key_bytes = data_key.plaintext_key;
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce.copy_from_slice(&parsed.iv[..12]);
|
||||
|
||||
let original_size = metadata
|
||||
.get("x-rustfs-encryption-original-size")
|
||||
.and_then(|s| s.parse::<i64>().ok());
|
||||
|
||||
Ok(Some((key_bytes, nonce, original_size)))
|
||||
}
|
||||
|
||||
pub(crate) fn derive_part_nonce(base: [u8; 12], part_number: usize) -> [u8; 12] {
|
||||
let mut nonce = base;
|
||||
let current = u32::from_be_bytes([nonce[8], nonce[9], nonce[10], nonce[11]]);
|
||||
let incremented = current.wrapping_add(part_number as u32);
|
||||
nonce[8..12].copy_from_slice(&incremented.to_be_bytes());
|
||||
nonce
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_multipart_managed_stream(
|
||||
mut encrypted_stream: Box<dyn AsyncRead + Unpin + Send + Sync>,
|
||||
parts: &[ObjectPartInfo],
|
||||
key_bytes: [u8; 32],
|
||||
base_nonce: [u8; 12],
|
||||
) -> Result<(Box<dyn Reader>, i64), StorageError> {
|
||||
let total_plain_capacity: usize = parts.iter().map(|part| part.actual_size.max(0) as usize).sum();
|
||||
|
||||
let mut plaintext = Vec::with_capacity(total_plain_capacity);
|
||||
|
||||
for part in parts {
|
||||
if part.size == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut encrypted_part = vec![0u8; part.size];
|
||||
tokio::io::AsyncReadExt::read_exact(&mut encrypted_stream, &mut encrypted_part)
|
||||
.await
|
||||
.map_err(|e| StorageError::other(format!("failed to read encrypted multipart segment {}: {}", part.number, e)))?;
|
||||
|
||||
let part_nonce = derive_part_nonce(base_nonce, part.number);
|
||||
let cursor = std::io::Cursor::new(encrypted_part);
|
||||
let mut decrypt_reader = DecryptReader::new(WarpReader::new(cursor), key_bytes, part_nonce);
|
||||
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut decrypt_reader, &mut plaintext)
|
||||
.await
|
||||
.map_err(|e| StorageError::other(format!("failed to decrypt multipart segment {}: {}", part.number, e)))?;
|
||||
}
|
||||
|
||||
let total_plain_size = plaintext.len() as i64;
|
||||
let reader = Box::new(WarpReader::new(InMemoryAsyncReader::new(plaintext))) as Box<dyn Reader>;
|
||||
|
||||
Ok((reader, total_plain_size))
|
||||
}
|
||||
|
||||
pub(crate) fn strip_managed_encryption_metadata(metadata: &mut HashMap<String, String>) {
|
||||
const KEYS: [&str; 7] = [
|
||||
"x-amz-server-side-encryption",
|
||||
"x-amz-server-side-encryption-aws-kms-key-id",
|
||||
"x-rustfs-encryption-iv",
|
||||
"x-rustfs-encryption-tag",
|
||||
"x-rustfs-encryption-key",
|
||||
"x-rustfs-encryption-context",
|
||||
"x-rustfs-encryption-original-size",
|
||||
];
|
||||
|
||||
for key in KEYS.iter() {
|
||||
metadata.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_managed_sse(algorithm: &ServerSideEncryption) -> bool {
|
||||
matches!(algorithm.as_str(), "AES256" | "aws:kms")
|
||||
}
|
||||
|
||||
/// Validate object key for control characters and log special characters
|
||||
///
|
||||
/// This function:
|
||||
|
||||
+31
-157
@@ -16,7 +16,7 @@
|
||||
mod tests {
|
||||
use crate::config::workload_profiles::WorkloadProfile;
|
||||
use crate::storage::ecfs::FS;
|
||||
use crate::storage::s3_api::common::rustfs_owner;
|
||||
use crate::storage::ecfs::RUSTFS_OWNER;
|
||||
use crate::storage::{
|
||||
apply_cors_headers, check_preconditions, get_adaptive_buffer_size_with_profile, get_buffer_size_opt_in, is_etag_equal,
|
||||
matches_origin_pattern, parse_etag, parse_object_lock_legal_hold, parse_object_lock_retention,
|
||||
@@ -27,7 +27,6 @@ mod tests {
|
||||
use rustfs_config::MI_B;
|
||||
use rustfs_ecstore::set_disk::DEFAULT_READ_BUFFER_SIZE;
|
||||
use rustfs_ecstore::store_api::ObjectInfo;
|
||||
use rustfs_policy::policy::{BucketPolicy, Validator};
|
||||
use rustfs_utils::http::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, RESERVED_METADATA_PREFIX_LOWER};
|
||||
use rustfs_zip::CompressionFormat;
|
||||
use s3s::dto::{
|
||||
@@ -67,12 +66,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rustfs_owner_helper() {
|
||||
// Test that rustfs owner metadata remains stable for S3 compatibility.
|
||||
let owner = rustfs_owner();
|
||||
assert!(!owner.display_name.as_ref().unwrap().is_empty());
|
||||
assert!(!owner.id.as_ref().unwrap().is_empty());
|
||||
assert_eq!(owner.display_name.as_ref().unwrap(), "rustfs");
|
||||
fn test_rustfs_owner_constant() {
|
||||
// Test that RUSTFS_OWNER constant is properly defined
|
||||
assert!(!RUSTFS_OWNER.display_name.as_ref().unwrap().is_empty());
|
||||
assert!(!RUSTFS_OWNER.id.as_ref().unwrap().is_empty());
|
||||
assert_eq!(RUSTFS_OWNER.display_name.as_ref().unwrap(), "RustFS Tester");
|
||||
}
|
||||
|
||||
// Note: Most S3 API methods require complex setup with global state, storage backend,
|
||||
@@ -800,127 +798,6 @@ mod tests {
|
||||
assert_eq!(result13.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_lock_retention() {
|
||||
use crate::storage::ecfs_extend::apply_lock_retention;
|
||||
use s3s::dto::{DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// [1] Normal case: Apply default retention with COMPLIANCE mode and days
|
||||
let mut metadata = HashMap::new();
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
assert_eq!(metadata.get("x-amz-object-lock-mode"), Some(&"COMPLIANCE".to_string()));
|
||||
assert!(metadata.contains_key("x-amz-object-lock-retain-until-date"));
|
||||
|
||||
// [2] Normal case: Apply default retention with GOVERNANCE mode and years
|
||||
let mut metadata = HashMap::new();
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
|
||||
days: None,
|
||||
years: Some(1),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
assert_eq!(metadata.get("x-amz-object-lock-mode"), Some(&"GOVERNANCE".to_string()));
|
||||
assert!(metadata.contains_key("x-amz-object-lock-retain-until-date"));
|
||||
|
||||
// [3] Skip case: No configuration provided
|
||||
let mut metadata = HashMap::new();
|
||||
apply_lock_retention(None, &mut metadata);
|
||||
assert!(!metadata.contains_key("x-amz-object-lock-mode"));
|
||||
assert!(!metadata.contains_key("x-amz-object-lock-retain-until-date"));
|
||||
|
||||
// [4] Skip case: Object Lock not enabled
|
||||
let mut metadata = HashMap::new();
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: None,
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
assert!(!metadata.contains_key("x-amz-object-lock-mode"));
|
||||
|
||||
// [5] Skip case: Explicit retention already set (explicit takes precedence)
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
||||
metadata.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string());
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
// Explicit retention should remain unchanged
|
||||
assert_eq!(metadata.get("x-amz-object-lock-mode"), Some(&"GOVERNANCE".to_string()));
|
||||
assert_eq!(
|
||||
metadata.get("x-amz-object-lock-retain-until-date"),
|
||||
Some(&"2030-01-01T00:00:00Z".to_string())
|
||||
);
|
||||
|
||||
// [6] Skip case: No default retention configured
|
||||
let mut metadata = HashMap::new();
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule { default_retention: None }),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
assert!(!metadata.contains_key("x-amz-object-lock-mode"));
|
||||
|
||||
// [7] Skip case: No retention mode specified
|
||||
let mut metadata = HashMap::new();
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: None,
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
assert!(!metadata.contains_key("x-amz-object-lock-mode"));
|
||||
|
||||
// [8] Skip case: No retention period specified (neither days nor years)
|
||||
let mut metadata = HashMap::new();
|
||||
let config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
days: None,
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
apply_lock_retention(config, &mut metadata);
|
||||
assert!(!metadata.contains_key("x-amz-object-lock-mode"));
|
||||
}
|
||||
|
||||
// Note: S3Request structure is complex and requires many fields.
|
||||
// For real testing, we would need proper integration test setup.
|
||||
// Removing this test as it requires too much S3 infrastructure setup.
|
||||
@@ -976,6 +853,31 @@ mod tests {
|
||||
assert_eq!(formatted, "550e8400-e29b-41d4-a716-446655440000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_objects_version_id_normalization() {
|
||||
use uuid::Uuid;
|
||||
|
||||
let fs = FS::new();
|
||||
|
||||
let (raw, uuid) = fs.normalize_delete_objects_version_id(Some("null".to_string())).unwrap();
|
||||
assert_eq!(raw.as_deref(), Some("null"));
|
||||
assert_eq!(uuid, Some(Uuid::nil()));
|
||||
|
||||
let valid = "550e8400-e29b-41d4-a716-446655440000".to_string();
|
||||
let (raw, uuid) = fs.normalize_delete_objects_version_id(Some(valid.clone())).unwrap();
|
||||
assert_eq!(raw.as_deref(), Some(valid.as_str()));
|
||||
assert_eq!(uuid, Some(Uuid::parse_str(&valid).unwrap()));
|
||||
|
||||
let err = fs
|
||||
.normalize_delete_objects_version_id(Some("not-a-uuid".to_string()))
|
||||
.unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
|
||||
let (raw, uuid) = fs.normalize_delete_objects_version_id(None).unwrap();
|
||||
assert!(raw.is_none());
|
||||
assert!(uuid.is_none());
|
||||
}
|
||||
|
||||
/// Test that ListObjectVersionsOutput markers are correctly set
|
||||
/// This verifies the fix for boto3 ParamValidationError
|
||||
#[test]
|
||||
@@ -1014,34 +916,6 @@ mod tests {
|
||||
assert_eq!(filtered_version_marker.unwrap(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucket_policy_round_trip_preserves_original_json_text() {
|
||||
let policy = r#"{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": [
|
||||
"arn:aws:s3:::example-bucket",
|
||||
"arn:aws:s3:::example-bucket/*"
|
||||
]
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let parsed: BucketPolicy = serde_json::from_str(policy).unwrap();
|
||||
assert!(parsed.is_valid().is_ok());
|
||||
|
||||
// Normalized serialization can differ (for example, Action becomes an array).
|
||||
let normalized = serde_json::to_string(&parsed).unwrap();
|
||||
assert_ne!(normalized, policy);
|
||||
|
||||
// Stored raw policy bytes must preserve exact text for GetBucketPolicy round trip.
|
||||
let stored = policy.as_bytes().to_vec();
|
||||
let round_trip = String::from_utf8(stored).unwrap();
|
||||
assert_eq!(round_trip, policy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_origin_pattern_exact_match() {
|
||||
// Test exact match
|
||||
|
||||
@@ -14,21 +14,17 @@
|
||||
|
||||
pub mod access;
|
||||
pub mod concurrency;
|
||||
#[cfg(test)]
|
||||
mod concurrent_get_object_test;
|
||||
pub mod ecfs;
|
||||
mod ecfs_extend;
|
||||
pub(crate) mod entity;
|
||||
pub(crate) mod helper;
|
||||
pub mod options;
|
||||
pub(crate) mod readers;
|
||||
pub(crate) mod s3_api;
|
||||
pub mod tonic_service;
|
||||
pub(crate) use ecfs_extend::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod concurrent_get_object_test;
|
||||
mod ecfs_extend;
|
||||
#[cfg(test)]
|
||||
mod ecfs_test;
|
||||
pub(crate) mod head_prefix;
|
||||
mod objects;
|
||||
mod sse;
|
||||
#[cfg(test)]
|
||||
mod sse_test;
|
||||
|
||||
pub(crate) use ecfs_extend::*;
|
||||
|
||||
Reference in New Issue
Block a user