From d1e9665c0a12c9b92e09510f6b469278ea52c71f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:26:42 +0000 Subject: [PATCH] dir-sim: a group's secondary proxies claim in the provisioning plane Review (Codex P1): a mail-enabled group's secondary smtp: proxy was invisible to provisioning_conflicts, because the snapshot kept proxy rows for users only and the plane read a group's primary SMTP alone. The snapshot now keeps the groups' proxyAddresses as their own relation (group_proxies, owner = group ordinal), and the provisioning plane adds each live group's secondary SMTP rows. Delivery is unchanged: it still reads a group by its primary address only. Test a_groups_secondary_proxy_claims_its_address: red with the relation present but unread, green after; address_owner still answers the user. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/lance-graph-dir-sim/src/snapshot.rs | 12 ++++++- crates/lance-graph-dir-sim/src/validate.rs | 20 ++++++++++-- .../lance-graph-dir-sim/tests/provisioning.rs | 32 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/lance-graph-dir-sim/src/snapshot.rs b/crates/lance-graph-dir-sim/src/snapshot.rs index 2d94f03fd..60cef9afe 100644 --- a/crates/lance-graph-dir-sim/src/snapshot.rs +++ b/crates/lance-graph-dir-sim/src/snapshot.rs @@ -603,6 +603,10 @@ pub struct Snapshot { pub(crate) groups: Population, /// `proxyAddresses` of the users, as rows. pub(crate) proxies: ProxyRelation, + /// `proxyAddresses` of the groups, as rows (owner = group ordinal). + /// Read only by the provisioning plane: delivery reads a group's primary + /// SMTP address alone. + pub(crate) group_proxies: ProxyRelation, pub(crate) m_user: Vec, pub(crate) m_group: Vec, pub(crate) m_all: Vec, @@ -637,7 +641,12 @@ impl Snapshot { if proxies.len() >= u32::MAX as usize { return Err(BuildError::TooManyProxies); } - let groups = Population::build(&groups, d); + let group_nodes = groups; + let groups = Population::build(&group_nodes, d); + let group_proxies = ProxyRelation::build(&group_nodes, &groups, d); + if group_proxies.len() >= u32::MAX as usize { + return Err(BuildError::TooManyProxies); + } let mut rows: Vec<(u16, u16)> = Vec::with_capacity(obs.members.len()); let mut unresolved = Vec::new(); for (u, g) in &obs.members { @@ -660,6 +669,7 @@ impl Snapshot { users, groups, proxies, + group_proxies, m_user, m_group, m_all, diff --git a/crates/lance-graph-dir-sim/src/validate.rs b/crates/lance-graph-dir-sim/src/validate.rs index 802b86fb4..0db3ac70e 100644 --- a/crates/lance-graph-dir-sim/src/validate.rs +++ b/crates/lance-graph-dir-sim/src/validate.rs @@ -668,8 +668,7 @@ pub struct ProvisioningConflict { /// mailbox-less account's leftover SMTP and proxies, and a group all claim /// their values. The claims read here are each present user's UPN, primary /// and secondary SMTP and routing address, and each live group's primary -/// SMTP, in one address space as [`address_rules`] reads it. `mail` claims -/// nothing. +/// and secondary SMTP, in one address space. `mail` claims nothing. /// /// This is a separate plane from the Exchange one. It does not feed /// [`address_owner`], [`address_recipient`] or [`validate`], so a conflict @@ -680,7 +679,22 @@ pub struct ProvisioningConflict { /// real tenant and are not in this plane. pub fn provisioning_conflicts(v: &View<'_>) -> Vec { let present = v.existing(NodeKind::User); - let rows = rows_over(v, &present, &present, &mut Vec::new()); + let mut rows = rows_over(v, &present, &present, &mut Vec::new()); + // A group's secondary SMTP proxies claim too. A group created in a + // version carries none (a `Change` sets no proxy). + let gp = &v.snap.group_proxies; + for i in 0..v.snap.groups.len() { + let Some(g) = v.guid_in(NodeKind::Group, i) else { + continue; + }; + for r in gp.owner_rows(i as u32) { + if gp.meta[r] == meta::SMTP_SECONDARY { + rows.push((gp.key[r], g, AddressRole::SecondarySmtp)); + } + } + } + rows.sort_unstable(); + rows.dedup(); rows.chunk_by(|a, b| a.0 == b.0) .filter(|run| run.iter().any(|r| r.1 != run[0].1)) .map(|run| ProvisioningConflict { diff --git a/crates/lance-graph-dir-sim/tests/provisioning.rs b/crates/lance-graph-dir-sim/tests/provisioning.rs index cf554eaaa..a54978d98 100644 --- a/crates/lance-graph-dir-sim/tests/provisioning.rs +++ b/crates/lance-graph-dir-sim/tests/provisioning.rs @@ -128,3 +128,35 @@ fn distinct_claims_and_one_object_under_two_roles_are_not_conflicts() { let view = st.view(v).unwrap(); assert_eq!(provisioning_conflicts(&view), vec![]); } + +// A group's secondary SMTP proxy claims like any other address: another +// object carrying it is a provisioning conflict. Delivery still reads a +// group by its primary address only, so the user keeps the address there. +#[test] +fn a_groups_secondary_proxy_claims_its_address() { + let mut grp = group("team@x.test"); + grp.proxies = vec!["smtp:sales@x.test".into()]; + let (mut st, v) = store(vec![ + (g(0xB0), grp), + ( + g(0xA1), + ObservedNode::user("alice.upn@x.test", "sales@x.test"), + ), + ]); + let key = { + let id = st.intern("sales@x.test"); + st.key_of(id).unwrap() + }; + let view = st.view(v).unwrap(); + assert_eq!(address_owner(&view, key), Ok(Some(g(0xA1)))); + assert_eq!( + provisioning_conflicts(&view), + vec![ProvisioningConflict { + key, + claims: vec![ + (g(0xA1), AddressRole::PrimarySmtp), + (g(0xB0), AddressRole::SecondarySmtp), + ], + }] + ); +}