diff --git a/crates/ecstore/src/set_disk/ops/object.rs b/crates/ecstore/src/set_disk/ops/object.rs index 60366350e..a84ccb077 100644 --- a/crates/ecstore/src/set_disk/ops/object.rs +++ b/crates/ecstore/src/set_disk/ops/object.rs @@ -2661,7 +2661,18 @@ impl SetDisks { let object_lock_config = opts.object_lock_config_snapshot.as_deref().ok_or_else(|| { Error::other("explicit-version PUT is missing its Object Lock configuration snapshot") })?; - if check_object_lock_for_deletion_with_state(object_lock_config.state(), &existing, false)?.is_some() { + // The WORM gate protects the locked version from local + // overwrites only. For an authorized replication write + // (ReplicateObjectAction, `replication_request`) the + // source's lock state governs the replica, as in MinIO's + // `checkPutObjectLockAllowed` (`!replica` guard): a + // legal-hold release or retention change reaches this + // site only through this write, and rejecting it loops + // through MRF forever. Receiver-side LWW below still + // keeps a category locked more recently here. + if !opts.replication_request + && check_object_lock_for_deletion_with_state(object_lock_config.state(), &existing, false)?.is_some() + { return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object.to_string())); } // Receiver-side LWW (rustfs/backlog#1953): reuse this @@ -8356,6 +8367,124 @@ mod replication_lww_tests { ); assert_eq!(get_str(&info.user_defined, SUFFIX_TAGGING_TIMESTAMP).as_deref(), Some(T_LOCAL)); } + + /// Destination version under an active legal hold at `hold_timestamp`, + /// plus an active COMPLIANCE retention (no retention timestamp). + async fn seed_locked_version(set_disks: &Arc, bucket: &str, object: &str, version_id: &str, hold_timestamp: &str) { + let mut local = HashMap::new(); + local.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "ON".to_string()); + insert_str(&mut local, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, hold_timestamp.to_string()); + local.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), "COMPLIANCE".to_string()); + local.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2099-01-01T00:00:00Z".to_string()); + put_version(set_disks, bucket, object, version_id, &versioned_opts(version_id, local)).await; + } + + fn inbound_legal_hold_release_opts(version_id: &str, timestamp: &str) -> ObjectOptions { + let mut inbound = HashMap::new(); + inbound.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "OFF".to_string()); + insert_str(&mut inbound, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, timestamp.to_string()); + inbound.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), "COMPLIANCE".to_string()); + inbound.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2099-01-01T00:00:00Z".to_string()); + ObjectOptions { + replication_request: true, + replication_legalhold_timestamp: Some(parse_ts(timestamp)), + ..versioned_opts(version_id, inbound) + } + } + + /// The source's lock state governs the replica: a legal-hold release (or a + /// retention change) can only reach this site through the authorized + /// replication write, so the commit-time WORM gate must not reject it + /// because the destination version is currently locked. + #[tokio::test] + async fn inbound_newer_legal_hold_release_updates_locked_version() { + let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; + let bucket = "lww-locked-release-newer"; + let object = "object"; + let version_id = Uuid::new_v4().to_string(); + make_bucket(&disk_stores, bucket).await; + seed_locked_version(&set_disks, bucket, object, &version_id, T_OLD).await; + + put_version( + &set_disks, + bucket, + object, + &version_id, + &inbound_legal_hold_release_opts(&version_id, T_NEW), + ) + .await; + + let info = version_info(&set_disks, bucket, object, &version_id).await; + assert_eq!( + info.user_defined.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), + Some("OFF"), + "a newer source-side legal hold release must be applied to the locked replica" + ); + assert_eq!(get_str(&info.user_defined, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP).as_deref(), Some(T_NEW)); + assert_eq!( + info.user_defined.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), + Some("COMPLIANCE"), + "the untouched retention category must survive the write" + ); + } + + /// Skipping the WORM gate for replication writes must not weaken LWW: a + /// stale inbound release still loses to a hold applied more recently here. + #[tokio::test] + async fn inbound_stale_legal_hold_release_keeps_newer_local_hold() { + let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; + let bucket = "lww-locked-release-stale"; + let object = "object"; + let version_id = Uuid::new_v4().to_string(); + make_bucket(&disk_stores, bucket).await; + seed_locked_version(&set_disks, bucket, object, &version_id, T_LOCAL).await; + + put_version( + &set_disks, + bucket, + object, + &version_id, + &inbound_legal_hold_release_opts(&version_id, T_OLD), + ) + .await; + + let info = version_info(&set_disks, bucket, object, &version_id).await; + assert_eq!( + info.user_defined.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), + Some("ON"), + "a stale inbound release must not lift a hold applied more recently on this site" + ); + assert_eq!( + get_str(&info.user_defined, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP).as_deref(), + Some(T_LOCAL) + ); + } + + /// The bypass is scoped to authorized replication writes: the same + /// explicit-version PUT without `replication_request` stays WORM-rejected. + #[tokio::test] + async fn non_replication_overwrite_of_locked_version_is_still_rejected() { + let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; + let bucket = "lww-locked-plain-put"; + let object = "object"; + let version_id = Uuid::new_v4().to_string(); + make_bucket(&disk_stores, bucket).await; + seed_locked_version(&set_disks, bucket, object, &version_id, T_OLD).await; + + let opts = ObjectOptions { + replication_request: false, + ..inbound_legal_hold_release_opts(&version_id, T_NEW) + }; + let mut reader = PutObjReader::from_vec(b"lww-body".to_vec()); + let err = set_disks + .put_object(bucket, object, &mut reader, &opts) + .await + .expect_err("a non-replication overwrite of a locked version must stay rejected"); + assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)), "unexpected error: {err}"); + + let info = version_info(&set_disks, bucket, object, &version_id).await; + assert_eq!(info.user_defined.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), Some("ON")); + } } #[cfg(test)] diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index a31ec1d19..cdb3a02a7 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -3938,6 +3938,15 @@ pub(crate) fn validate_existing_object_lock_for_write(existing_obj_info: &Object if put_like_write_creates_new_version(opts) { return Ok(()); } + // An authorized replication write (ReplicateObjectAction set + // `replication_request`) carries the source's lock state for the replica, + // so the destination's current hold/retention must not reject it (MinIO + // `checkPutObjectLockAllowed` skips the existing-version check for + // replicas). The set layer's commit-lock LWW still keeps a category that + // was locked more recently on this site. + if opts.replication_request { + return Ok(()); + } let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined); if legal_hold @@ -11037,6 +11046,24 @@ mod tests { assert_eq!(err.code(), &S3ErrorCode::AccessDenied); } + /// The source's lock state governs the replica (rustfs/backlog#1953): + /// an authorized replication write may overwrite a locked version; the + /// set layer's LWW still 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, + ..Default::default() + }; + + validate_existing_object_lock_for_write(&compliance_retained_object_info(), &opts) + .expect("replication write must bypass the destination COMPLIANCE lock"); + validate_existing_object_lock_for_write(&legal_hold_object_info(), &opts) + .expect("replication write must bypass the destination legal hold"); + } + #[test] fn is_put_object_extract_requested_accepts_meta_header() { let mut headers = HeaderMap::new();