diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index fae8c6a75..bf5d852c7 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -33,7 +33,8 @@ Decision 1(c): observed claims, provisioning conflicts and delivery are three qu | D-id | Deliverable | Status | Gate | |---|---|---|---| -| **D-DSV-1** | `validate::provisioning_conflicts`: every present object's claims, separate from `address_owner` / `address_recipient` / `validate` | In PR | `tests/provisioning.rs`: delivery unchanged, conflict reported, silence half; two disable runs red | +| **D-DSV-1** | `validate::provisioning_conflicts`: every present object's claims, separate from `address_owner` / `address_recipient` / `validate` | Shipped (#1477, #1480) | `tests/provisioning.rs`: delivery unchanged, conflict reported, silence half; two disable runs red | +| **D-DSV-2** | A mail-enabled group's secondary SMTP proxies are in the Exchange address space (a group without a primary SMTP address claims them only in the provisioning plane): mail to them reaches the group (`address_owner` / `address_recipient`), and an object sharing one is an `AddressConflict` | In PR | `tests/provisioning.rs`: `mail_to_a_groups_secondary_proxy_reaches_the_group` red before; `a_groups_secondary_proxy_claims_its_address` re-pinned from owner to conflict; `a_non_mail_enabled_groups_proxy_is_only_a_provisioning_claim` red before (Codex P1) | ## D-DSP — dir-sim history that survives a restart (2026-10-11) diff --git a/crates/lance-graph-dir-sim/src/validate.rs b/crates/lance-graph-dir-sim/src/validate.rs index 0db3ac70e..3b92b4293 100644 --- a/crates/lance-graph-dir-sim/src/validate.rs +++ b/crates/lance-graph-dir-sim/src/validate.rs @@ -459,7 +459,8 @@ pub fn address_rules(v: &View<'_>) -> Vec { /// The single directory object that holds `key` in the one address space /// [`address_rules`] checks, read from the same rows: UPN, primary and /// secondary SMTP and the routing address of address owners; the primary -/// SMTP address of every live group. `mail` holds nothing. +/// and secondary SMTP addresses of every mail-enabled group (one with a +/// primary SMTP address). `mail` holds nothing. /// /// - `Ok(None)`: no live directory object holds the key. /// - `Ok(Some(owner))`: exactly one does, under one or more roles (a user @@ -541,17 +542,24 @@ pub fn address_recipient_in( fn address_rows(v: &View<'_>, out: &mut Vec) -> Vec<(u32, Guid128, AddressRole)> { // The UPN is held by an enabled account or a live recipient; the mail // addresses are provisioned by the recipient type alone. - rows_over(v, &v.owner_users(), &v.mail_owner_users(), out) + rows_over(v, &v.owner_users(), &v.mail_owner_users(), false, out) } /// The `(key, holder, role)` rows of the users in `owners` (their UPN) /// and `mail_owners` (their primary and secondary SMTP and routing -/// address), and of every live group (its primary SMTP), sorted and -/// deduplicated. Routing violations of `mail_owners` go to `out`. +/// address), and of every live group (its primary and secondary SMTP), +/// sorted and deduplicated. Routing violations of `mail_owners` go to `out`. +/// +/// `every_group_proxy` decides which groups' secondary proxies count: in +/// Exchange (`false`) only a mail-enabled group's, the one that has a +/// primary SMTP address, since a group that is not mail-enabled holds and +/// receives at nothing; in the provisioning plane (`true`) every present +/// group's, since Entra keeps proxy addresses unique whatever the object. fn rows_over( v: &View<'_>, owners: &[u64], mail_owners: &[u64], + every_group_proxy: bool, out: &mut Vec, ) -> Vec<(u32, Guid128, AddressRole)> { let p = &v.snap.users; @@ -615,19 +623,30 @@ fn rows_over( out.push(Violation::RoutingMismatch { node: g }); } } - // A mail-enabled group's primary SMTP address is in the same space: a - // group is a recipient too (a distribution list), and Exchange refuses - // an address another object holds, whatever kind that object is. + // A mail-enabled group's SMTP addresses are in the same space: a group + // is a recipient too (a distribution list), Exchange delivers mail sent + // to any of its SMTP proxies, and it refuses an address another object + // holds, whatever kind that object is. A group created in a version + // carries no secondary proxies (a `Change` sets none), so only observed + // groups have proxy rows. + let gp = &v.snap.group_proxies; for i in 0..v.groups_len() { let Some(g) = v.guid_in(NodeKind::Group, i) else { continue; }; - if let Some((_, k)) = v + let primary = v .attr_ids((NodeKind::Group, i), Attribute::PrimarySmtp) - .filter(|&(_, k)| k != NONE) - { + .filter(|&(_, k)| k != NONE); + if let Some((_, k)) = primary { rows.push((k, g, AddressRole::PrimarySmtp)); } + if (every_group_proxy || primary.is_some()) && i < v.snap.groups.len() { + 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(); @@ -679,22 +698,7 @@ 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 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(); + let rows = rows_over(v, &present, &present, true, &mut Vec::new()); 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 a54978d98..9047dfc7b 100644 --- a/crates/lance-graph-dir-sim/tests/provisioning.rs +++ b/crates/lance-graph-dir-sim/tests/provisioning.rs @@ -130,8 +130,11 @@ fn distinct_claims_and_one_object_under_two_roles_are_not_conflicts() { } // 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. +// object carrying it is a provisioning conflict. It is also held in +// Exchange (mail to it reaches the group), so a user carrying the same +// address shares the key and the address names no single recipient. +// (Before #45 delivery read a group by its primary address only, and this +// test pinned the user as the owner.) #[test] fn a_groups_secondary_proxy_claims_its_address() { let mut grp = group("team@x.test"); @@ -148,7 +151,80 @@ fn a_groups_secondary_proxy_claims_its_address() { st.key_of(id).unwrap() }; let view = st.view(v).unwrap(); + let holders = vec![ + (g(0xA1), AddressRole::PrimarySmtp), + (g(0xB0), AddressRole::SecondarySmtp), + ]; + assert_eq!( + address_owner(&view, key), + Err(Violation::AddressConflict { + key, + holders: holders.clone() + }) + ); + assert_eq!( + address_recipient(&view, key), + Err(Violation::AddressConflict { + key, + holders: holders.clone() + }) + ); + assert_eq!( + provisioning_conflicts(&view), + vec![ProvisioningConflict { + key, + claims: vec![ + (g(0xA1), AddressRole::PrimarySmtp), + (g(0xB0), AddressRole::SecondarySmtp), + ], + }] + ); +} + +// Exchange delivers mail sent to any of a distribution group's SMTP +// proxies, not only its primary address: the secondary proxy is held by +// the group and delivers to it. +#[test] +fn mail_to_a_groups_secondary_proxy_reaches_the_group() { + let mut grp = group("team@x.test"); + grp.proxies = vec!["smtp:sales@x.test".into()]; + let (mut st, v) = store(vec![(g(0xB0), grp)]); + 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(0xB0)))); + assert_eq!(address_recipient(&view, key), Ok(Some(g(0xB0)))); + assert_eq!(st.validate(v).unwrap(), vec![]); +} + +// A group with no primary SMTP address is not mail-enabled: its leftover +// secondary proxy is not held in Exchange and delivers nowhere, so a user +// carrying the same address keeps it. The leftover still claims the key in +// the provisioning plane. +#[test] +fn a_non_mail_enabled_groups_proxy_is_only_a_provisioning_claim() { + let mut grp = ObservedNode::group(); + 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!( + !view.is_mail_recipient(&g(0xB0)), + "fixture: not mail-enabled" + ); assert_eq!(address_owner(&view, key), Ok(Some(g(0xA1)))); + assert_eq!(address_recipient(&view, key), Ok(Some(g(0xA1)))); assert_eq!( provisioning_conflicts(&view), vec![ProvisioningConflict {