mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-30 00:47:13 +00:00
fix(object-lock): unblock authorized replication writes on locked versions and tolerate cleared lock metadata (#6413)
This commit is contained in:
@@ -524,9 +524,10 @@ impl DefaultMultipartUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?,
|
||||
);
|
||||
let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?;
|
||||
let previous_current_sizes = match store.get_object_info(&bucket, &key, ¤t_opts).await {
|
||||
Ok(existing_obj_info) => {
|
||||
validate_existing_object_lock_for_write(&existing_obj_info, ¤t_opts)?;
|
||||
validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, ¤t_opts)?;
|
||||
let physical_size = existing_obj_info.size.max(0) as u64;
|
||||
let logical_size = quota_object_size(&existing_obj_info);
|
||||
Some((physical_size, logical_size))
|
||||
@@ -897,7 +898,9 @@ impl DefaultMultipartUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
match store.get_object_info(&bucket, &key, ¤t_opts).await {
|
||||
Ok(existing_obj_info) => validate_existing_object_lock_for_write(&existing_obj_info, &opts)?,
|
||||
Ok(existing_obj_info) => {
|
||||
validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?
|
||||
}
|
||||
Err(err) => {
|
||||
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
|
||||
return Err(ApiError::from(err).into());
|
||||
|
||||
@@ -39,7 +39,7 @@ use super::storage_api::object_usecase::bucket::{
|
||||
metadata_sys,
|
||||
object_lock::{
|
||||
objectlock::{get_object_legalhold_meta, get_object_retention_meta},
|
||||
objectlock_sys::{check_object_lock_for_deletion, is_retention_active},
|
||||
objectlock_sys::{check_object_lock_for_deletion, is_retention_active, replication_write_may_pass_worm_gate},
|
||||
},
|
||||
predict_lifecycle_expiration,
|
||||
quota::{QuotaCheckResult, QuotaError, QuotaOperation},
|
||||
@@ -3934,10 +3934,33 @@ fn put_like_write_creates_new_version(opts: &ObjectOptions) -> bool {
|
||||
opts.version_id.is_none() && opts.versioned && !opts.version_suspended
|
||||
}
|
||||
|
||||
pub(crate) fn validate_existing_object_lock_for_write(existing_obj_info: &ObjectInfo, opts: &ObjectOptions) -> S3Result<()> {
|
||||
pub(crate) fn validate_existing_object_lock_for_write(
|
||||
object_lock_config_state: &metadata_sys::ObjectLockConfigState,
|
||||
existing_obj_info: &ObjectInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> S3Result<()> {
|
||||
if put_like_write_creates_new_version(opts) {
|
||||
return Ok(());
|
||||
}
|
||||
// An authorized replication write may replace the locked version only
|
||||
// when the set layer's commit-lock LWW will judge every locking category,
|
||||
// judged against the bucket's authoritative lock state (default retention
|
||||
// included) exactly like the set-layer gate, which re-checks the same
|
||||
// rule under the lock. A non-authoritative state or malformed lock
|
||||
// metadata fails closed here.
|
||||
if opts.replication_request {
|
||||
let may_pass = replication_write_may_pass_worm_gate(object_lock_config_state, existing_obj_info, opts).map_err(|_| {
|
||||
S3Error::with_message(S3ErrorCode::AccessDenied, "Object Lock state could not be verified.".to_string())
|
||||
})?;
|
||||
return if may_pass {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(S3Error::with_message(
|
||||
S3ErrorCode::AccessDenied,
|
||||
"Object is locked and the replication write carries no source lock decision for it.".to_string(),
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined);
|
||||
if legal_hold
|
||||
@@ -6104,7 +6127,7 @@ impl DefaultObjectUsecase {
|
||||
};
|
||||
Some(match previous_current_info {
|
||||
Ok(existing_obj_info) => {
|
||||
validate_existing_object_lock_for_write(&existing_obj_info, &opts)?;
|
||||
validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?;
|
||||
Some(if quota_enabled {
|
||||
quota_object_size(&existing_obj_info).map_err(ApiError::from)?
|
||||
} else {
|
||||
@@ -7790,7 +7813,7 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
let previous_current_sizes = match store.get_object_info(&bucket, &key, ¤t_opts).await {
|
||||
Ok(existing_obj_info) => {
|
||||
validate_existing_object_lock_for_write(&existing_obj_info, &dst_opts)?;
|
||||
validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &dst_opts)?;
|
||||
if let Some(expected) = expected_current_version_id.as_deref()
|
||||
&& existing_obj_info.version_id.unwrap_or_default().to_string() != expected
|
||||
{
|
||||
@@ -10990,9 +11013,28 @@ mod tests {
|
||||
assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED));
|
||||
}
|
||||
|
||||
const NO_BUCKET_LOCK: metadata_sys::ObjectLockConfigState = metadata_sys::ObjectLockConfigState::ConfirmedAbsent;
|
||||
|
||||
fn bucket_default_retention_state(mode: &'static str) -> metadata_sys::ObjectLockConfigState {
|
||||
metadata_sys::ObjectLockConfigState::Configured {
|
||||
config: s3s::dto::ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(s3s::dto::ObjectLockEnabled::from_static(s3s::dto::ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(s3s::dto::ObjectLockRule {
|
||||
default_retention: Some(s3s::dto::DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
||||
days: Some(1),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
updated_at: OffsetDateTime::now_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_info_with_lock_metadata(metadata: HashMap<String, String>) -> ObjectInfo {
|
||||
ObjectInfo {
|
||||
user_defined: Arc::new(metadata),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -11031,7 +11073,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
validate_existing_object_lock_for_write(&compliance_retained_object_info(), &opts)
|
||||
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
||||
.expect("versioned put should create a new version");
|
||||
}
|
||||
|
||||
@@ -11043,14 +11085,18 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
validate_existing_object_lock_for_write(&legal_hold_object_info(), &opts)
|
||||
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts)
|
||||
.expect("versioned put should create a new version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_existing_object_lock_blocks_unversioned_compliance_overwrite() {
|
||||
let err = validate_existing_object_lock_for_write(&compliance_retained_object_info(), &ObjectOptions::default())
|
||||
.expect_err("unversioned overwrite should still be blocked");
|
||||
let err = validate_existing_object_lock_for_write(
|
||||
&NO_BUCKET_LOCK,
|
||||
&compliance_retained_object_info(),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.expect_err("unversioned overwrite should still be blocked");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
}
|
||||
@@ -11063,7 +11109,7 @@ mod tests {
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_existing_object_lock_for_write(&compliance_retained_object_info(), &opts)
|
||||
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
||||
.expect_err("suspended versioning overwrite should still be blocked");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
@@ -11076,12 +11122,86 @@ mod tests {
|
||||
version_id: Some(Uuid::new_v4().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_existing_object_lock_for_write(&compliance_retained_object_info(), &opts)
|
||||
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
||||
.expect_err("explicit version overwrite should still be blocked");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
}
|
||||
|
||||
/// The source's lock state governs the replica (rustfs/backlog#1953):
|
||||
/// an authorized replication write carrying the locking category's source
|
||||
/// timestamp may overwrite a locked version; the set layer's LWW then
|
||||
/// decides per category.
|
||||
#[test]
|
||||
fn validate_existing_object_lock_allows_authorized_replication_overwrite() {
|
||||
let opts = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::new_v4().to_string()),
|
||||
replication_request: true,
|
||||
replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
replication_legalhold_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
||||
.expect("replication write must bypass the destination COMPLIANCE lock");
|
||||
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts)
|
||||
.expect("replication write must bypass the destination legal hold");
|
||||
}
|
||||
|
||||
/// Without the locking category's source timestamp the LWW merge cannot
|
||||
/// judge it, so the write stays rejected instead of lifting the lock.
|
||||
#[test]
|
||||
fn validate_existing_object_lock_rejects_replication_overwrite_without_lock_timestamp() {
|
||||
let opts = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::new_v4().to_string()),
|
||||
replication_request: true,
|
||||
replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
||||
.expect_err("COMPLIANCE lock must hold without a retention source timestamp");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts)
|
||||
.expect_err("legal hold must hold without a legal-hold source timestamp");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
}
|
||||
|
||||
/// The bucket default retention locks a version without explicit
|
||||
/// retention keys; the pre-check judges the same authoritative state as
|
||||
/// the set-layer gate, so a tagging-only replication write is rejected
|
||||
/// and one carrying the retention source timestamp passes to LWW.
|
||||
#[test]
|
||||
fn validate_existing_object_lock_judges_bucket_default_retention_for_replication_overwrite() {
|
||||
let default_protected = object_info_with_lock_metadata(HashMap::new());
|
||||
let tagging_only = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::new_v4().to_string()),
|
||||
replication_request: true,
|
||||
replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let with_retention_decision = ObjectOptions {
|
||||
replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..tagging_only.clone()
|
||||
};
|
||||
|
||||
for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] {
|
||||
let state = bucket_default_retention_state(mode);
|
||||
let err = validate_existing_object_lock_for_write(&state, &default_protected, &tagging_only)
|
||||
.expect_err("bucket default retention must hold without a retention source timestamp");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied, "{mode}");
|
||||
validate_existing_object_lock_for_write(&state, &default_protected, &with_retention_decision)
|
||||
.expect("the retention source timestamp hands the default retention to LWW");
|
||||
}
|
||||
|
||||
// Without a bucket default the same version is simply unlocked.
|
||||
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &default_protected, &tagging_only)
|
||||
.expect("no bucket default, no lock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_put_object_extract_requested_accepts_meta_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -592,6 +592,16 @@ pub(crate) mod bucket {
|
||||
retain_until_date,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn replication_write_may_pass_worm_gate(
|
||||
state: &crate::storage::storage_api::ecstore_bucket::metadata_sys::ObjectLockConfigState,
|
||||
obj_info: &crate::storage::storage_api::ObjectInfo,
|
||||
opts: &crate::storage::storage_api::StorageObjectOptions,
|
||||
) -> Result<bool, crate::storage::storage_api::StorageError> {
|
||||
crate::storage::storage_api::ecstore_bucket::object_lock::objectlock_sys::replication_write_may_pass_worm_gate(
|
||||
state, obj_info, opts,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user