mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-02 02:08:41 +00:00
fix(replication): let replicated version purges pass the peer WORM gate (#6960)
A replicated version purge reaches the peer without the governance bypass header, so a GOVERNANCE-retained version deleted on the source with x-amz-bypass-governance-retention was rejected by the peer's WORM deletion gate forever: retryStats ended at a permanent failed count and the sites stayed diverged (issue #6850). The source is authoritative for such a purge: the same WORM gate already ran there, and GOVERNANCE retention with an authorized bypass is the only lock state it can purge through. The peer's commit-time deletion gate now treats an authorized replication delete addressed to an explicit version as carrying that judged bypass, reusing the same trust judgment as the replication write exemption (ObjectOptions::replication_request, set only after the handler authorized ReplicateDeleteAction). COMPLIANCE retention and legal hold keep blocking replicated purges, and a plain client delete without the bypass header stays rejected.
This commit is contained in:
@@ -177,6 +177,28 @@ pub fn replication_write_may_pass_worm_gate(
|
|||||||
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
|
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an authorized replication delete (`ObjectOptions::replication_request`)
|
||||||
|
/// addressed to an explicit version may bypass GOVERNANCE retention on the
|
||||||
|
/// local replica, exactly as an `x-amz-bypass-governance-retention` caller
|
||||||
|
/// with the bypass permission would.
|
||||||
|
///
|
||||||
|
/// The source is authoritative for a replicated version purge (issue #6850):
|
||||||
|
/// the same WORM deletion gate already ran there, and GOVERNANCE retention
|
||||||
|
/// with an authorized bypass is the only lock state it can purge through.
|
||||||
|
/// Requiring the bypass header again here makes the purge permanently
|
||||||
|
/// undeliverable — replication senders never carry it — and the sites diverge
|
||||||
|
/// forever. COMPLIANCE retention and legal hold stay blocking: the source
|
||||||
|
/// gate can never purge through them, so a replication purge that meets one
|
||||||
|
/// here is divergence or forgery and fails closed.
|
||||||
|
///
|
||||||
|
/// The trust judgment is the same one the write-path exemption uses:
|
||||||
|
/// `replication_request` is only set once the receiving handler has
|
||||||
|
/// authorized the caller for the replication action
|
||||||
|
/// (`ReplicateDeleteAction`), never straight from request headers.
|
||||||
|
pub fn replication_delete_may_bypass_governance(opts: &ObjectOptions) -> bool {
|
||||||
|
opts.replication_request && opts.version_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if an object is locked based on its metadata.
|
/// Check if an object is locked based on its metadata.
|
||||||
/// This is a common function used by both lifecycle evaluation and deletion checks.
|
/// This is a common function used by both lifecycle evaluation and deletion checks.
|
||||||
///
|
///
|
||||||
@@ -680,6 +702,32 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("modification time"));
|
assert!(err.to_string().contains("modification time"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The replicated-purge GOVERNANCE bypass (#6850) applies only to an
|
||||||
|
/// authorized replication delete addressed to an explicit version: a
|
||||||
|
/// local delete never gets it, and a replicated delete without a version
|
||||||
|
/// id creates a delete marker rather than purging anything.
|
||||||
|
#[test]
|
||||||
|
fn replication_delete_bypasses_governance_only_for_authorized_version_purges() {
|
||||||
|
let version_purge = ObjectOptions {
|
||||||
|
replication_request: true,
|
||||||
|
version_id: Some("6b6ffbc0-b0d3-4a86-8f6c-fe19163b8dcd".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(replication_delete_may_bypass_governance(&version_purge));
|
||||||
|
|
||||||
|
let local_version_delete = ObjectOptions {
|
||||||
|
replication_request: false,
|
||||||
|
..version_purge.clone()
|
||||||
|
};
|
||||||
|
assert!(!replication_delete_may_bypass_governance(&local_version_delete));
|
||||||
|
|
||||||
|
let replicated_marker_creation = ObjectOptions {
|
||||||
|
version_id: None,
|
||||||
|
..version_purge
|
||||||
|
};
|
||||||
|
assert!(!replication_delete_may_bypass_governance(&replicated_marker_creation));
|
||||||
|
}
|
||||||
|
|
||||||
/// A local PutObjectRetention / PutObjectLegalHold "clear" persists the
|
/// A local PutObjectRetention / PutObjectLegalHold "clear" persists the
|
||||||
/// lock keys as empty strings (the MinIO on-disk shape, see
|
/// lock keys as empty strings (the MinIO on-disk shape, see
|
||||||
/// `parse_object_lock_retention`); that is "no lock", not corruption, and
|
/// `parse_object_lock_retention`); that is "no lock", not corruption, and
|
||||||
|
|||||||
@@ -196,13 +196,17 @@ const METRIC_VERSION_IDENTITY_DRIFT_TOTAL: &str = "rustfs_replication_version_id
|
|||||||
/// after a restart is acceptable.
|
/// after a restart is acceptable.
|
||||||
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
|
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
|
||||||
|
|
||||||
/// Version purges the peer denied under object lock (#6850). Replication
|
/// Version purges the peer denied under object lock (#6850). A RustFS peer
|
||||||
/// carries no governance bypass, so such a purge cannot succeed until the
|
/// with the replicated-purge GOVERNANCE exemption
|
||||||
/// lock on the replica lapses — retrying every heal cycle only burns
|
/// (`replication_delete_may_bypass_governance`) no longer produces this for
|
||||||
/// bandwidth and failure counters. Entries suppress heal requeues for the
|
/// governance retention, but COMPLIANCE retention, legal hold, and targets
|
||||||
/// backoff window; after it expires one probe runs again, so the purge still
|
/// without the exemption (older RustFS, MinIO, generic S3) still deny — and
|
||||||
/// converges on its own once retention ends. In-process only: a restart
|
/// such a purge cannot succeed until the lock on the replica lapses, so
|
||||||
/// costs at most one extra probe per entry.
|
/// retrying every heal cycle only burns bandwidth and failure counters.
|
||||||
|
/// Entries suppress heal requeues for the backoff window; after it expires
|
||||||
|
/// one probe runs again, so the purge still converges on its own once
|
||||||
|
/// retention ends. In-process only: a restart costs at most one extra probe
|
||||||
|
/// per entry.
|
||||||
const OBJECT_LOCK_DENIED_PURGE_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
const OBJECT_LOCK_DENIED_PURGE_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||||
const OBJECT_LOCK_DENIED_PURGE_CACHE_MAX: usize = 4096;
|
const OBJECT_LOCK_DENIED_PURGE_CACHE_MAX: usize = 4096;
|
||||||
type ObjectLockDeniedPurgeKey = (String, String, String);
|
type ObjectLockDeniedPurgeKey = (String, String, String);
|
||||||
@@ -2830,11 +2834,12 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
|||||||
let object_lock_denied = is_version_purge && is_object_lock_denied_delete(e.code.as_deref(), e.message.as_deref());
|
let object_lock_denied = is_version_purge && is_object_lock_denied_delete(e.code.as_deref(), e.message.as_deref());
|
||||||
if object_lock_denied {
|
if object_lock_denied {
|
||||||
// Terminal for as long as the lock holds: the peer retains
|
// Terminal for as long as the lock holds: the peer retains
|
||||||
// this version and replication carries no governance bypass
|
// this version under COMPLIANCE retention or legal hold, or
|
||||||
// (#6850), so the sites stay diverged until the retention or
|
// is a target without the replicated-purge GOVERNANCE
|
||||||
// legal hold on the replica lapses. Surface it loudly instead
|
// exemption (#6850), so the sites stay diverged until the
|
||||||
// of letting a silent failed counter and a hot heal-retry
|
// lock on the replica lapses. Surface it loudly instead of
|
||||||
// loop stand in for the divergence.
|
// letting a silent failed counter and a hot heal-retry loop
|
||||||
|
// stand in for the divergence.
|
||||||
record_object_lock_denied_purge(dobj, &tgt_client.arn);
|
record_object_lock_denied_purge(dobj, &tgt_client.arn);
|
||||||
error!(
|
error!(
|
||||||
event = EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED,
|
event = EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED,
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
|||||||
use crate::bucket::metadata_sys;
|
use crate::bucket::metadata_sys;
|
||||||
use crate::bucket::metadata_sys::ObjectLockConfigState;
|
use crate::bucket::metadata_sys::ObjectLockConfigState;
|
||||||
use crate::bucket::object_lock::objectlock_sys::{
|
use crate::bucket::object_lock::objectlock_sys::{
|
||||||
check_object_lock_for_deletion_with_state, check_retention_for_modification, replication_write_may_pass_worm_gate,
|
check_object_lock_for_deletion_with_state, check_retention_for_modification, replication_delete_may_bypass_governance,
|
||||||
|
replication_write_may_pass_worm_gate,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::bucket::replication::ReplicationState;
|
use crate::bucket::replication::ReplicationState;
|
||||||
@@ -5578,10 +5579,15 @@ async fn check_object_lock_delete(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An authorized replicated version purge already passed this gate on the
|
||||||
|
// source with the bypass it carried there, so it clears GOVERNANCE
|
||||||
|
// retention here without the header; COMPLIANCE and legal hold still
|
||||||
|
// block below (see `replication_delete_may_bypass_governance`, #6850).
|
||||||
let bypass_governance = opts
|
let bypass_governance = opts
|
||||||
.object_lock_delete
|
.object_lock_delete
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|delete_opts| delete_opts.bypass_governance);
|
.is_some_and(|delete_opts| delete_opts.bypass_governance)
|
||||||
|
|| replication_delete_may_bypass_governance(opts);
|
||||||
let blocked = match opts.object_lock_config_snapshot.as_deref() {
|
let blocked = match opts.object_lock_config_snapshot.as_deref() {
|
||||||
Some(snapshot) => check_object_lock_for_deletion_with_state(snapshot.state(), obj_info, bypass_governance)?.is_some(),
|
Some(snapshot) => check_object_lock_for_deletion_with_state(snapshot.state(), obj_info, bypass_governance)?.is_some(),
|
||||||
None => {
|
None => {
|
||||||
@@ -11582,6 +11588,100 @@ mod tests {
|
|||||||
.expect("versioned delete marker creation should not delete the locked version");
|
.expect("versioned delete marker creation should not delete the locked version");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn governance_retained_obj_info() -> ObjectInfo {
|
||||||
|
let retain_until = OffsetDateTime::now_utc() + Duration::from_secs(60 * 60 * 24 * 60);
|
||||||
|
let mut user_defined = HashMap::new();
|
||||||
|
user_defined.insert(
|
||||||
|
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||||
|
s3s::dto::ObjectLockRetentionMode::GOVERNANCE.to_string(),
|
||||||
|
);
|
||||||
|
user_defined.insert(
|
||||||
|
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
|
||||||
|
retain_until.format(&time::format_description::well_known::Rfc3339).unwrap(),
|
||||||
|
);
|
||||||
|
ObjectInfo {
|
||||||
|
user_defined: Arc::new(user_defined),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explicit_version_delete_opts(replication_request: bool) -> ObjectOptions {
|
||||||
|
ObjectOptions {
|
||||||
|
version_id: Some(Uuid::new_v4().to_string()),
|
||||||
|
versioned: true,
|
||||||
|
replication_request,
|
||||||
|
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(ObjectLockConfigState::ConfirmedAbsent))),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #6850: a replicated version purge carries no bypass header, so the
|
||||||
|
// GOVERNANCE gate must honor the source's already-judged bypass instead of
|
||||||
|
// keeping the sites permanently diverged.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_object_lock_delete_allows_replicated_governance_version_purge() {
|
||||||
|
let obj_info = governance_retained_obj_info();
|
||||||
|
let opts = explicit_version_delete_opts(true);
|
||||||
|
|
||||||
|
check_object_lock_delete(&bootstrap_ctx(), "bucket", "object", &obj_info, &opts)
|
||||||
|
.await
|
||||||
|
.expect("an authorized replicated version purge must pass GOVERNANCE retention (#6850)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_object_lock_delete_blocks_plain_governance_version_delete_without_bypass() {
|
||||||
|
let obj_info = governance_retained_obj_info();
|
||||||
|
let opts = explicit_version_delete_opts(false);
|
||||||
|
|
||||||
|
let err = check_object_lock_delete(&bootstrap_ctx(), "bucket", "object", &obj_info, &opts)
|
||||||
|
.await
|
||||||
|
.expect_err("a plain client delete without the bypass header must stay blocked by GOVERNANCE retention");
|
||||||
|
|
||||||
|
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_object_lock_delete_blocks_replicated_compliance_version_purge() {
|
||||||
|
let retain_until = OffsetDateTime::now_utc() + Duration::from_secs(60 * 60 * 24 * 60);
|
||||||
|
let mut user_defined = HashMap::new();
|
||||||
|
user_defined.insert(
|
||||||
|
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||||
|
s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||||
|
);
|
||||||
|
user_defined.insert(
|
||||||
|
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
|
||||||
|
retain_until.format(&time::format_description::well_known::Rfc3339).unwrap(),
|
||||||
|
);
|
||||||
|
let obj_info = ObjectInfo {
|
||||||
|
user_defined: Arc::new(user_defined),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = explicit_version_delete_opts(true);
|
||||||
|
|
||||||
|
let err = check_object_lock_delete(&bootstrap_ctx(), "bucket", "object", &obj_info, &opts)
|
||||||
|
.await
|
||||||
|
.expect_err("the source gate can never purge through COMPLIANCE, so a replicated purge fails closed");
|
||||||
|
|
||||||
|
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_object_lock_delete_blocks_replicated_legal_hold_version_purge() {
|
||||||
|
let mut user_defined = HashMap::new();
|
||||||
|
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "ON".to_string());
|
||||||
|
let obj_info = ObjectInfo {
|
||||||
|
user_defined: Arc::new(user_defined),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = explicit_version_delete_opts(true);
|
||||||
|
|
||||||
|
let err = check_object_lock_delete(&bootstrap_ctx(), "bucket", "object", &obj_info, &opts)
|
||||||
|
.await
|
||||||
|
.expect_err("the source gate can never purge through a legal hold, so a replicated purge fails closed");
|
||||||
|
|
||||||
|
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)));
|
||||||
|
}
|
||||||
|
|
||||||
// backlog#929 (HP-8): the delete_objects per-object stat is gated on the
|
// backlog#929 (HP-8): the delete_objects per-object stat is gated on the
|
||||||
// bucket object-lock configuration. Lock-enabled buckets (either legacy
|
// bucket object-lock configuration. Lock-enabled buckets (either legacy
|
||||||
// lock_enabled flag or an enabled ObjectLockConfiguration) and unknown
|
// lock_enabled flag or an enabled ObjectLockConfiguration) and unknown
|
||||||
|
|||||||
Reference in New Issue
Block a user