fix(object-lock): unblock authorized replication writes on locked versions and tolerate cleared lock metadata (#6413)

This commit is contained in:
唐小鸭
2026-08-23 22:35:23 +08:00
committed by GitHub
parent 0d15ce1865
commit 3ce01dcc73
7 changed files with 803 additions and 72 deletions
+5 -2
View File
@@ -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, &current_opts).await {
Ok(existing_obj_info) => {
validate_existing_object_lock_for_write(&existing_obj_info, &current_opts)?;
validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &current_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, &current_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());
+130 -10
View File
@@ -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, &current_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();
+10
View File
@@ -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,
)
}
}
}