diff --git a/crates/iam/src/manager.rs b/crates/iam/src/manager.rs index d7831fc89..30090a240 100644 --- a/crates/iam/src/manager.rs +++ b/crates/iam/src/manager.rs @@ -2320,9 +2320,13 @@ fn filter_policies_from_docs(policy_docs: &CacheEntity, policy_name: } fn build_group_desc(name: &str, group_info: GroupInfo, mapped_policy: Option) -> GroupDesc { + // A group without a mapped policy must report the group's own stored + // timestamp, not the wall clock: this value feeds content-addressed + // consumers (the site-replication repair plan hashes it), and a fresh + // clock on every read makes equal states hash differently. let (policy, updated_at) = mapped_policy .map(|policy| (policy.policies, Some(policy.update_at))) - .unwrap_or_else(|| (String::new(), Some(OffsetDateTime::now_utc()))); + .unwrap_or_else(|| (String::new(), group_info.update_at)); GroupDesc { name: name.to_string(), @@ -2347,6 +2351,46 @@ mod tests { }; use tokio::sync::Notify; + #[test] + fn test_build_group_desc_without_mapping_reports_the_stored_group_timestamp() { + let stamp = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let group_info = GroupInfo { + version: 1, + status: "enabled".to_string(), + members: vec!["alice".to_string()], + update_at: Some(stamp), + }; + + let first = build_group_desc("team", group_info.clone(), None); + let second = build_group_desc("team", group_info, None); + + assert_eq!(first.updated_at, Some(stamp)); + assert_eq!(first.updated_at, second.updated_at); + assert!(first.policy.is_empty()); + } + + #[test] + fn test_build_group_desc_with_mapping_reports_the_mapping_timestamp() { + let group_stamp = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let mapping_stamp = OffsetDateTime::from_unix_timestamp(1_700_000_500).expect("valid timestamp"); + let group_info = GroupInfo { + version: 1, + status: "enabled".to_string(), + members: vec![], + update_at: Some(group_stamp), + }; + let mapping = MappedPolicy { + version: 1, + policies: "readwrite".to_string(), + update_at: mapping_stamp, + }; + + let desc = build_group_desc("team", group_info, Some(mapping)); + + assert_eq!(desc.updated_at, Some(mapping_stamp)); + assert_eq!(desc.policy, "readwrite"); + } + #[derive(Clone)] struct FailingInitialLoadStore; diff --git a/rustfs/src/site_replication/tests.rs b/rustfs/src/site_replication/tests.rs index 720fd6e2a..938de2710 100644 --- a/rustfs/src/site_replication/tests.rs +++ b/rustfs/src/site_replication/tests.rs @@ -1124,6 +1124,44 @@ fn test_site_replication_repair_dry_run_plan_is_non_mutating_and_redacted() { ); } +#[test] +fn test_site_replication_repair_preflight_token_is_deterministic_for_equal_state() { + // dry-run hands the operator a preflight token that execute must match, so + // equal state must hash equally across calls. A policy-less group used to + // break this: its desc was stamped with the wall clock on every read, and + // the flapping task id made execute permanently reject the token as stale. + let state = SiteReplicationState { + name: "local".to_string(), + service_account_access_key: "site-replicator-0".to_string(), + peers: BTreeMap::from([( + "remote-dep".to_string(), + PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }, + )]), + ..Default::default() + }; + let mut info = SRInfo::default(); + info.group_desc_map.insert( + "policyless".to_string(), + rustfs_madmin::GroupDesc { + name: "policyless".to_string(), + status: "enabled".to_string(), + members: vec!["alice".to_string()], + policy: String::new(), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + }, + ); + + let plan_a = site_replication_bootstrap_plan(&info).expect("first plan"); + let plan_b = site_replication_bootstrap_plan(&info).expect("second plan"); + let token_a = site_replication_repair_preflight_token(&state, &plan_a, b"test-signing-key").expect("first token"); + let token_b = site_replication_repair_preflight_token(&state, &plan_b, b"test-signing-key").expect("second token"); + + assert_eq!(token_a, token_b); +} + #[test] fn test_site_replication_repair_preflight_detects_stale_snapshot() { let mut state = SiteReplicationState {