From e642383339643afed771ff7acab00e426c4472e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:26:50 +0000 Subject: [PATCH 1/8] Exchange hybrid recipient model: ingest the msExch triplet, decode it strictly, and simulate the remote-mailbox lifecycle What an object is to Exchange comes from msExchRemoteRecipientType, msExchRecipientDisplayType, msExchRecipientTypeDetails and targetAddress, not from the account's enabled flag: shared, room and equipment mailboxes are disabled accounts and still recipients. ogar-ad, schema v2: ingests the triplet raw. The remote recipient type and the (signed, bit-cast) display type take numeric slots 1 and 2; the 64-bit type details keep their LDAP decimal text in pooled slot 14. ogar-dir-sim::exchange: - Recipient decodes the attributes strictly: exactly the 26 msExchRemoteRecipientType values the hybrid lifecycle produces (the table of jahube/PShell RemoteMailbox-Archive/Enable-Remotemailbox.PS1), with their display and type codes and, while the mailbox exists, its routing address; an on-premises mailbox with its remote-archive state; not mail-enabled. Anything else is Other, kept raw. A decoded state re-encodes to exactly its input. - RemoteMailboxOp is the lifecycle: Enable (user / room / equipment, with routing address), EnableArchive, DisableArchive, CompleteMove, SetType, Disable. Each step refuses outside the table (NotApplicable / NotInTable). - NodeState gains recipient: Option (None = not read, distinct from NotMailEnabled); Change::SetRecipient is compare-and-set through NodeState::apply; a plan lowers it to the single lifecycle step joining the two roles (Operation::RemoteMailbox, Precondition::RecipientEquals), or refuses it as NotActuatable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 33 +- crates/ogar-ad/tests/main.rs | 26 ++ crates/ogar-dir-sim/src/change.rs | 54 ++- crates/ogar-dir-sim/src/exchange.rs | 691 ++++++++++++++++++++++++++++ crates/ogar-dir-sim/src/lib.rs | 5 + crates/ogar-dir-sim/src/plan.rs | 82 +++- docs/DIRECTORY-SIMULATION-POC.md | 22 + 7 files changed, 902 insertions(+), 11 deletions(-) create mode 100644 crates/ogar-dir-sim/src/exchange.rs diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index 0322b783..475bf885 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -23,14 +23,15 @@ use ogar_dir_core::{ }; /// Encoder schema version understood by this crate. -pub const SCHEMA_VERSION: u16 = 1; +pub const SCHEMA_VERSION: u16 = 2; /// This crate's schema id. pub const SCHEMA: SchemaId = SchemaId { family: SchemaFamily::AdDs, version: SCHEMA_VERSION, }; -/// The embedded AD attribute table, v1. +/// The embedded AD attribute table. Append-only: each entry carries the +/// schema version that introduced it (`since`); the name is kept from v1. /// /// Selection: the attributes needed to (a) name an account in every form AD /// and Exchange use (`sAMAccountName`, `userPrincipalName`, `mail`, @@ -38,8 +39,14 @@ pub const SCHEMA: SchemaId = SchemaId { /// (`objectClass`, `objectSid`, `userAccountControl`), (c) say where it is /// (`distinguishedName`) and (d) date it (`whenCreated`, `whenChanged`), plus /// the display triplet. Group membership (`memberOf`/`member`) is a relation -/// and becomes edges later, never an inline list. Further Exchange attributes -/// (`msExch*`) are deferred until a consumer needs them. +/// and becomes edges later, never an inline list. +/// +/// v2 adds the Exchange recipient triplet, raw, for the hybrid recipient +/// model (`ogar-dir-sim::exchange`): `msExchRemoteRecipientType` (flags, +/// numeric), `msExchRecipientDisplayType` (signed, numeric, bit-cast like +/// every signed LDAP integer here) and `msExchRecipientTypeDetails` (64-bit, +/// so kept as its LDAP decimal text in a pooled slot). Other `msExch*` +/// attributes stay out until a consumer needs them. pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -131,6 +138,24 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::U32, since: 1, }, + AttrDef { + name: "msExchRemoteRecipientType", + slot: 1, + kind: AttrKind::U32, + since: 2, + }, + AttrDef { + name: "msExchRecipientDisplayType", + slot: 2, + kind: AttrKind::U32, + since: 2, + }, + AttrDef { + name: "msExchRecipientTypeDetails", + slot: 14, + kind: AttrKind::Str, + since: 2, + }, ]; /// AD object kinds (family-scoped codes in `object_kind`). diff --git a/crates/ogar-ad/tests/main.rs b/crates/ogar-ad/tests/main.rs index 7160beb6..4fc4553d 100644 --- a/crates/ogar-ad/tests/main.rs +++ b/crates/ogar-ad/tests/main.rs @@ -225,3 +225,29 @@ fn ldifde_changetype_add_is_accepted_other_changetypes_refused() { ); } } + +// v2: the Exchange recipient triplet of a remote shared mailbox, as the +// on-premises directory holds it. The display type is signed (bit-cast into +// its u32 slot); the 64-bit type details stay their LDAP decimal text. +#[test] +fn exchange_recipient_attributes_are_kept_raw() { + let text = "dn: CN=Team,OU=Shared,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nuserAccountControl: 514\nmsExchRemoteRecipientType: 100\nmsExchRecipientDisplayType: -2147483642\nmsExchRecipientTypeDetails: 34359738368\ntargetAddress: SMTP:team@tenant.mail.onmicrosoft.com\n"; + let e = &ldif::parse(text).unwrap()[0]; + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + let enc = encode(e, domain(), &mut dict, &mut pool, 1).unwrap(); + assert!(enc.ignored.is_empty(), "{:?}", enc.ignored); + let r = enc.record; + let num = |n: &str| SCHEMA_V1.iter().find(|d| d.name == n).unwrap().slot as usize; + assert_eq!(r.num(num("msExchRemoteRecipientType")), Some(100)); + assert_eq!( + r.num(num("msExchRecipientDisplayType")).map(|n| n as i32), + Some(-2_147_483_642) + ); + assert_eq!( + pool.get(r.str_ref(slot("msExchRecipientTypeDetails")).unwrap()) + .unwrap(), + b"34359738368" + ); + // The account is disabled: a shared mailbox is a disabled account. + assert_eq!(r.num(0), Some(514)); +} diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index 80cab398..2bb7b2f9 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -20,6 +20,7 @@ //! through simulation, the desired version, reconciliation and the plan. They //! are not snapshot ordinals. +use crate::exchange::Recipient; use ogar_dir_core::{Dn128, Guid128}; /// Stable identity of one exact raw attribute value (case and spacing kept). @@ -62,6 +63,10 @@ pub struct NodeState { /// Hierarchy location (numeric, within the store's directory scope; /// never a DN), if known. pub dn: Option, + /// Exchange recipient role (`msExch*` + `targetAddress`); `None` = not + /// read by the source (unknown), which is different from + /// [`Recipient::NotMailEnabled`]. + pub recipient: Option, } /// The one definition of "active" across sources (V4, decided 2026-10-07): @@ -119,9 +124,9 @@ pub enum Attribute { /// 2. `DeleteNode` — frees its UPN / SMTP before anything claims them; /// 3. `SetAttribute` — renames away from (or into) addresses only after /// deletes freed them and before creates take them; -/// 4. `SetActive`, `SetLocation` — properties of a node that exists before -/// and after; neither frees nor claims a value, so no other change -/// depends on them; +/// 4. `SetActive`, `SetLocation`, `SetRecipient` — properties of a node +/// that exists before and after; none frees or claims a value, so no +/// other change depends on them; /// 5. `CreateNode` — after every address it needs is free; /// 6. `AddMembership` — once both endpoints exist. /// @@ -196,6 +201,17 @@ pub enum Change { /// New location. to: Option, }, + /// Compare-and-set of the Exchange recipient role. A plan can actuate it + /// only where one lifecycle step joins `from` and `to` + /// ([`RemoteMailboxOp::between`](crate::RemoteMailboxOp::between)). + SetRecipient { + /// Object. + node: Guid128, + /// Role the change expects to replace (as observed). + from: Option, + /// New role. + to: Option, + }, /// A node that did not exist comes into existence. CreateNode { /// Identity. @@ -270,6 +286,12 @@ impl NodeState { } next.dn = *to; } + Change::SetRecipient { from, to, .. } => { + if self.recipient != *from { + return Err(Refusal::Stale); + } + next.recipient = *to; + } Change::RemoveMembership { .. } | Change::DeleteNode { .. } | Change::CreateNode { .. } @@ -339,6 +361,7 @@ mod tests { upn: Some(ValueId(1)), primary_smtp: Some(ValueId(2)), dn, + recipient: None, } } fn dn(l: &[u8]) -> Option { @@ -381,6 +404,7 @@ mod tests { upn: None, primary_smtp: None, dn: None, + recipient: None, }; for (from, to) in [(Some(true), Some(false)), (Some(true), Some(true))] { assert_eq!( @@ -474,6 +498,30 @@ mod tests { } } + // The recipient role is compare-and-set like every other property, and + // unknown (`None`) is distinct from "not mail-enabled". + #[test] + fn set_recipient_is_compare_and_set() { + use crate::exchange::Recipient; + let mut s = user(None, None); + s.recipient = Some(Recipient::NotMailEnabled); + let to = Some(Recipient::OnPremisesMailbox { + archive: crate::exchange::ArchiveState::None, + }); + let ok = Change::SetRecipient { + node: N, + from: Some(Recipient::NotMailEnabled), + to, + }; + assert_eq!(s.apply(&ok).map(|n| n.recipient), Ok(to)); + let unknown = Change::SetRecipient { + node: N, + from: None, + to, + }; + assert_eq!(s.apply(&unknown), Err(Refusal::Stale)); + } + // The sorted order places the two new property changes after renames // and before creates. #[test] diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs new file mode 100644 index 00000000..5c4c7d1a --- /dev/null +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -0,0 +1,691 @@ +//! Exchange hybrid recipients, as the on-premises directory records them. +//! +//! Three attributes say what an object is to Exchange: +//! +//! * `msExchRemoteRecipientType` — flags for an Exchange Online (remote) +//! mailbox seen from on-premises: provision / migrated / deprovision of the +//! mailbox, provision / deprovision of the archive, room / equipment +//! (both = shared); +//! * `msExchRecipientDisplayType` — a signed display class; +//! * `msExchRecipientTypeDetails` — a 64-bit type code. +//! +//! plus `targetAddress`, which for a remote mailbox is its routing address +//! (`alias@tenant.mail.onmicrosoft.com`). +//! +//! The decode accepts exactly the combinations the hybrid lifecycle +//! produces (the 26 `msExchRemoteRecipientType` values of +//! `jahube/PShell RemoteMailbox-Archive/Enable-Remotemailbox.PS1`, with the +//! type and display codes that go with them). Anything else is kept raw as +//! [`Recipient::Other`] and never guessed into a nearby state. +//! +//! **The enabled flag is not part of this.** A shared, room or equipment +//! mailbox is a disabled account by design and is still a recipient. + +use crate::change::ValueId; + +/// `msExchRemoteRecipientType` flag bits. +pub mod flag { + /// ProvisionMailbox — a remote mailbox created in Exchange Online. + pub const PROVISION_MAILBOX: u32 = 1; + /// ProvisionArchive. + pub const PROVISION_ARCHIVE: u32 = 2; + /// Migrated — the mailbox was moved to Exchange Online. + pub const MIGRATED: u32 = 4; + /// DeprovisionMailbox. + pub const DEPROVISION_MAILBOX: u32 = 8; + /// DeprovisionArchive. + pub const DEPROVISION_ARCHIVE: u32 = 16; + /// RoomMailbox. + pub const ROOM: u32 = 32; + /// EquipmentMailbox. + pub const EQUIPMENT: u32 = 64; + /// SharedMailbox (room and equipment bits together). + pub const SHARED: u32 = ROOM | EQUIPMENT; +} + +/// `msExchRecipientTypeDetails` values this vocabulary decodes. +pub mod type_details { + /// On-premises user mailbox. + pub const USER_MAILBOX: u64 = 1; + /// RemoteUserMailbox. + pub const REMOTE_USER_MAILBOX: u64 = 2_147_483_648; + /// RemoteRoomMailbox. + pub const REMOTE_ROOM_MAILBOX: u64 = 8_589_934_592; + /// RemoteEquipmentMailbox. + pub const REMOTE_EQUIPMENT_MAILBOX: u64 = 17_179_869_184; + /// RemoteSharedMailbox. + pub const REMOTE_SHARED_MAILBOX: u64 = 34_359_738_368; +} + +/// `msExchRecipientDisplayType` values this vocabulary decodes. +pub mod display_type { + /// On-premises (ACL-able) mailbox user. + pub const MAILBOX_USER: i32 = 1_073_741_824; + /// Synced remote user (and shared) mailbox. + pub const REMOTE_USER_MAILBOX: i32 = -2_147_483_642; + /// Synced remote room mailbox. + pub const REMOTE_ROOM_MAILBOX: i32 = -2_147_481_850; + /// Synced remote equipment mailbox. + pub const REMOTE_EQUIPMENT_MAILBOX: i32 = -2_147_481_594; +} + +/// The four recipient attributes as stored. `None` = attribute absent. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RecipientAttributes { + /// `msExchRemoteRecipientType`. + pub remote_recipient_type: Option, + /// `msExchRecipientDisplayType`. + pub display_type: Option, + /// `msExchRecipientTypeDetails`. + pub type_details: Option, + /// `targetAddress` (the address, without its `SMTP:` prefix). + pub target_address: Option, +} + +/// What a remote mailbox is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RemoteKind { + /// A user's mailbox. + User, + /// A room. + Room, + /// Equipment. + Equipment, + /// A shared mailbox. + Shared, +} + +/// Where the remote mailbox is in its lifecycle. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailboxState { + /// Created directly in Exchange Online (`Enable-RemoteMailbox`). + Provisioned, + /// Moved from on-premises to Exchange Online. + Migrated, + /// Removed (`Disable-RemoteMailbox`); the object is no longer a mail + /// recipient, the flags tell the sync to remove the cloud mailbox. + Deprovisioned, +} + +/// The archive. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ArchiveState { + /// No archive was ever provisioned. + None, + /// An Exchange Online archive. + Provisioned, + /// The archive was removed. + Deprovisioned, +} + +/// A remote mailbox state that the hybrid lifecycle can produce. Built only +/// through [`RemoteMailbox::new`], so an unreachable combination cannot be +/// represented. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RemoteMailbox { + kind: RemoteKind, + mailbox: MailboxState, + archive: ArchiveState, + routing: Option, +} + +/// One object's role in Exchange. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Recipient { + /// No recipient attribute at all. + NotMailEnabled, + /// A mailbox hosted on-premises, with its Exchange Online archive state + /// (`msExchRemoteRecipientType` 2 / 16, or absent). + OnPremisesMailbox { + /// Archive. + archive: ArchiveState, + }, + /// A remote (Exchange Online) mailbox. + RemoteMailbox(RemoteMailbox), + /// Any other combination — a mail user, contact, group, or a value this + /// vocabulary does not decode. Kept raw. + Other(RecipientAttributes), +} + +/// The `msExchRemoteRecipientType` values the lifecycle produces. A remote +/// mailbox state is valid iff its code is listed; codes 2 and 16 belong to +/// an on-premises mailbox with a remote archive. +pub const REMOTE_RECIPIENT_TYPES: [u32; 26] = [ + 1, 2, 3, 4, 6, 8, 10, 16, 17, 20, 24, // user + 33, 35, 36, 38, 49, 52, // room + 65, 67, 68, 70, 81, 84, // equipment + 100, 102, 116, // shared +]; + +fn archive_bits(a: ArchiveState) -> u32 { + match a { + ArchiveState::None => 0, + ArchiveState::Provisioned => flag::PROVISION_ARCHIVE, + ArchiveState::Deprovisioned => flag::DEPROVISION_ARCHIVE, + } +} + +impl RemoteMailbox { + /// A remote mailbox, if the lifecycle can produce it. A provisioned or + /// migrated mailbox carries its routing address; a deprovisioned one + /// has none (the attributes are removed with it). + pub fn new( + kind: RemoteKind, + mailbox: MailboxState, + archive: ArchiveState, + routing: Option, + ) -> Option { + let m = Self { + kind, + mailbox, + archive, + routing, + }; + let routed = mailbox != MailboxState::Deprovisioned; + (REMOTE_RECIPIENT_TYPES.contains(&m.code()) && routing.is_some() == routed).then_some(m) + } + /// Kind. + pub fn kind(&self) -> RemoteKind { + self.kind + } + /// Mailbox state. + pub fn mailbox(&self) -> MailboxState { + self.mailbox + } + /// Archive state. + pub fn archive(&self) -> ArchiveState { + self.archive + } + /// Routing address (`targetAddress`). + pub fn routing(&self) -> Option { + self.routing + } + /// `msExchRemoteRecipientType`. + pub fn code(&self) -> u32 { + let mailbox = match self.mailbox { + MailboxState::Provisioned => flag::PROVISION_MAILBOX, + MailboxState::Migrated => flag::MIGRATED, + MailboxState::Deprovisioned => flag::DEPROVISION_MAILBOX, + }; + let kind = match self.kind { + RemoteKind::User => 0, + RemoteKind::Room => flag::ROOM, + RemoteKind::Equipment => flag::EQUIPMENT, + RemoteKind::Shared => flag::SHARED, + }; + mailbox | archive_bits(self.archive) | kind + } + /// `(display type, type details)` while the mailbox exists; none once it + /// is deprovisioned. + fn types(&self) -> Option<(i32, u64)> { + if self.mailbox == MailboxState::Deprovisioned { + return None; + } + Some(match self.kind { + RemoteKind::User => ( + display_type::REMOTE_USER_MAILBOX, + type_details::REMOTE_USER_MAILBOX, + ), + RemoteKind::Room => ( + display_type::REMOTE_ROOM_MAILBOX, + type_details::REMOTE_ROOM_MAILBOX, + ), + RemoteKind::Equipment => ( + display_type::REMOTE_EQUIPMENT_MAILBOX, + type_details::REMOTE_EQUIPMENT_MAILBOX, + ), + RemoteKind::Shared => ( + display_type::REMOTE_USER_MAILBOX, + type_details::REMOTE_SHARED_MAILBOX, + ), + }) + } + fn decode_code(code: u32) -> Option<(RemoteKind, MailboxState, ArchiveState)> { + if !REMOTE_RECIPIENT_TYPES.contains(&code) { + return None; + } + let kind = match code & flag::SHARED { + 0 => RemoteKind::User, + flag::ROOM => RemoteKind::Room, + flag::EQUIPMENT => RemoteKind::Equipment, + _ => RemoteKind::Shared, + }; + let mailbox = if code & flag::PROVISION_MAILBOX != 0 { + MailboxState::Provisioned + } else if code & flag::MIGRATED != 0 { + MailboxState::Migrated + } else if code & flag::DEPROVISION_MAILBOX != 0 { + MailboxState::Deprovisioned + } else { + return None; // 2 / 16: an on-premises mailbox, not a remote one + }; + let archive = if code & flag::PROVISION_ARCHIVE != 0 { + ArchiveState::Provisioned + } else if code & flag::DEPROVISION_ARCHIVE != 0 { + ArchiveState::Deprovisioned + } else { + ArchiveState::None + }; + Some((kind, mailbox, archive)) + } +} + +impl Recipient { + /// Decode stored attributes. Strict: a decoded state re-encodes to + /// exactly `a`; everything else is [`Recipient::Other`]. + pub fn from_attributes(a: RecipientAttributes) -> Self { + let decoded = Self::decode(a); + match decoded { + Some(r) if r.attributes() == a => r, + _ => Self::Other(a), + } + } + fn decode(a: RecipientAttributes) -> Option { + if a == RecipientAttributes::default() { + return Some(Self::NotMailEnabled); + } + if a.type_details == Some(type_details::USER_MAILBOX) { + let archive = match a.remote_recipient_type { + None => ArchiveState::None, + Some(flag::PROVISION_ARCHIVE) => ArchiveState::Provisioned, + Some(flag::DEPROVISION_ARCHIVE) => ArchiveState::Deprovisioned, + Some(_) => return None, + }; + return Some(Self::OnPremisesMailbox { archive }); + } + let (kind, mailbox, archive) = RemoteMailbox::decode_code(a.remote_recipient_type?)?; + RemoteMailbox::new(kind, mailbox, archive, a.target_address).map(Self::RemoteMailbox) + } + /// The stored attributes of this recipient. + pub fn attributes(&self) -> RecipientAttributes { + match self { + Self::NotMailEnabled => RecipientAttributes::default(), + Self::OnPremisesMailbox { archive } => RecipientAttributes { + remote_recipient_type: (*archive != ArchiveState::None) + .then(|| archive_bits(*archive)), + display_type: Some(display_type::MAILBOX_USER), + type_details: Some(type_details::USER_MAILBOX), + target_address: None, + }, + Self::RemoteMailbox(m) => { + let types = m.types(); + RecipientAttributes { + remote_recipient_type: Some(m.code()), + display_type: types.map(|t| t.0), + type_details: types.map(|t| t.1), + target_address: m.routing, + } + } + Self::Other(a) => *a, + } + } + /// Whether Exchange treats the object as a recipient that owns its + /// addresses. A deprovisioned remote mailbox and a non-mail-enabled + /// object do not; everything else (including [`Recipient::Other`]) does. + pub fn is_recipient(&self) -> bool { + match self { + Self::NotMailEnabled => false, + Self::RemoteMailbox(m) => m.mailbox != MailboxState::Deprovisioned, + Self::OnPremisesMailbox { .. } | Self::Other(_) => true, + } + } +} + +/// One step of the hybrid remote-mailbox lifecycle (semantic; the actuator +/// maps it to its own commands). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RemoteMailboxOp { + /// `Enable-RemoteMailbox [-Room | -Equipment] -RemoteRoutingAddress`: + /// a non-mail-enabled user gets a provisioned remote mailbox. + Enable { + /// User, room or equipment (there is no shared variant of enable). + kind: RemoteKind, + /// Routing address. + routing: ValueId, + }, + /// `Enable-RemoteMailbox -Archive` (remote mailbox) or + /// `Enable-Mailbox -RemoteArchive` (on-premises mailbox). + EnableArchive, + /// `Disable-RemoteMailbox -Archive` / `Disable-Mailbox -RemoteArchive`. + DisableArchive, + /// A completed move of an on-premises mailbox to Exchange Online. + CompleteMove { + /// Routing address of the moved mailbox. + routing: ValueId, + }, + /// `Set-RemoteMailbox -Type`: change what the remote mailbox is. + SetType(RemoteKind), + /// `Disable-RemoteMailbox`. + Disable, +} + +/// Why a lifecycle step does not apply. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LifecycleRefusal { + /// The step does not start from this state. + NotApplicable, + /// The step would reach a state the lifecycle does not produce. + NotInTable, +} + +impl RemoteMailboxOp { + /// The state after this step. + /// + /// # Errors + /// + /// [`LifecycleRefusal`]. + pub fn apply(self, from: &Recipient) -> Result { + use LifecycleRefusal::{NotApplicable, NotInTable}; + let remote = |kind, mailbox, archive, routing| { + RemoteMailbox::new(kind, mailbox, archive, routing) + .map(Recipient::RemoteMailbox) + .ok_or(NotInTable) + }; + let live = |m: &RemoteMailbox| m.mailbox != MailboxState::Deprovisioned; + match (self, from) { + (Self::Enable { kind, routing }, Recipient::NotMailEnabled) => { + if kind == RemoteKind::Shared { + return Err(NotInTable); + } + remote( + kind, + MailboxState::Provisioned, + ArchiveState::None, + Some(routing), + ) + } + (Self::EnableArchive, Recipient::RemoteMailbox(m)) + if live(m) && m.archive != ArchiveState::Provisioned => + { + remote(m.kind, m.mailbox, ArchiveState::Provisioned, m.routing) + } + (Self::EnableArchive, Recipient::OnPremisesMailbox { archive }) + if *archive != ArchiveState::Provisioned => + { + Ok(Recipient::OnPremisesMailbox { + archive: ArchiveState::Provisioned, + }) + } + (Self::DisableArchive, Recipient::RemoteMailbox(m)) + if live(m) && m.archive == ArchiveState::Provisioned => + { + remote(m.kind, m.mailbox, ArchiveState::Deprovisioned, m.routing) + } + ( + Self::DisableArchive, + Recipient::OnPremisesMailbox { + archive: ArchiveState::Provisioned, + }, + ) => Ok(Recipient::OnPremisesMailbox { + archive: ArchiveState::Deprovisioned, + }), + (Self::CompleteMove { routing }, Recipient::OnPremisesMailbox { archive }) => remote( + RemoteKind::User, + MailboxState::Migrated, + *archive, + Some(routing), + ), + (Self::SetType(kind), Recipient::RemoteMailbox(m)) if live(m) && m.kind != kind => { + remote(kind, m.mailbox, m.archive, m.routing) + } + (Self::Disable, Recipient::RemoteMailbox(m)) if live(m) => { + remote(m.kind, MailboxState::Deprovisioned, m.archive, None) + } + _ => Err(NotApplicable), + } + } + + /// The one step that takes `from` to `to`, if exactly one does. + pub fn between(from: &Recipient, to: &Recipient) -> Option { + let routing = match to { + Recipient::RemoteMailbox(m) => m.routing, + _ => None, + }; + let kinds = [ + RemoteKind::User, + RemoteKind::Room, + RemoteKind::Equipment, + RemoteKind::Shared, + ]; + let mut candidates = vec![Self::EnableArchive, Self::DisableArchive, Self::Disable]; + candidates.extend(kinds.map(Self::SetType)); + if let Some(routing) = routing { + candidates.extend(kinds.map(|kind| Self::Enable { kind, routing })); + candidates.push(Self::CompleteMove { routing }); + } + let mut hits = candidates + .into_iter() + .filter(|op| op.apply(from).as_ref() == Ok(to)); + let first = hits.next()?; + hits.next().is_none().then_some(first) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const R: ValueId = ValueId(42); + + fn attrs(rrt: Option, display: Option, details: Option) -> RecipientAttributes { + RecipientAttributes { + remote_recipient_type: rrt, + display_type: display, + type_details: details, + target_address: None, + } + } + + // Every code 0..=255: exactly the 26 listed codes decode to a lifecycle + // state (with the type codes and routing address that belong to them); + // every other code stays raw. Each decoded state re-encodes to its code. + #[test] + fn exactly_the_26_listed_remote_recipient_types_decode() { + let mut decoded = Vec::new(); + for code in 0..=255u32 { + let mut hit = None; + for kind in [ + RemoteKind::User, + RemoteKind::Room, + RemoteKind::Equipment, + RemoteKind::Shared, + ] { + for mailbox in [ + MailboxState::Provisioned, + MailboxState::Migrated, + MailboxState::Deprovisioned, + ] { + for archive in [ + ArchiveState::None, + ArchiveState::Provisioned, + ArchiveState::Deprovisioned, + ] { + let routing = (mailbox != MailboxState::Deprovisioned).then_some(R); + if let Some(m) = RemoteMailbox::new(kind, mailbox, archive, routing) + && m.code() == code + { + let r = Recipient::RemoteMailbox(m); + assert_eq!(Recipient::from_attributes(r.attributes()), r); + hit = Some(code); + } + } + } + } + for archive in [ArchiveState::Provisioned, ArchiveState::Deprovisioned] { + let r = Recipient::OnPremisesMailbox { archive }; + if r.attributes().remote_recipient_type == Some(code) { + assert_eq!(Recipient::from_attributes(r.attributes()), r); + hit = Some(code); + } + } + decoded.extend(hit); + } + assert_eq!(decoded, REMOTE_RECIPIENT_TYPES.to_vec()); + } + + // The script's own example: Enable-RemoteMailbox then -Archive gives + // msExchRemoteRecipientType 3, DisplayType -2147483642, TypeDetails + // 2147483648. + #[test] + fn enable_then_archive_matches_the_script() { + let r = RemoteMailboxOp::Enable { + kind: RemoteKind::User, + routing: R, + } + .apply(&Recipient::NotMailEnabled) + .unwrap(); + let r = RemoteMailboxOp::EnableArchive.apply(&r).unwrap(); + assert_eq!( + r.attributes(), + RecipientAttributes { + remote_recipient_type: Some(3), + display_type: Some(-2_147_483_642), + type_details: Some(2_147_483_648), + target_address: Some(R), + } + ); + } + + // A walk through the table's transitions, each landing on the listed + // code. + #[test] + fn lifecycle_transitions_land_on_listed_codes() { + let code = |r: &Recipient| r.attributes().remote_recipient_type; + let room = RemoteMailboxOp::Enable { + kind: RemoteKind::Room, + routing: R, + } + .apply(&Recipient::NotMailEnabled) + .unwrap(); + assert_eq!(code(&room), Some(33)); + let room = RemoteMailboxOp::EnableArchive.apply(&room).unwrap(); + assert_eq!(code(&room), Some(35)); + assert_eq!( + code(&RemoteMailboxOp::DisableArchive.apply(&room).unwrap()), + Some(49) + ); + + let onprem = Recipient::OnPremisesMailbox { + archive: ArchiveState::None, + }; + let archived = RemoteMailboxOp::EnableArchive.apply(&onprem).unwrap(); + assert_eq!(code(&archived), Some(2)); + let moved = RemoteMailboxOp::CompleteMove { routing: R } + .apply(&archived) + .unwrap(); + assert_eq!(code(&moved), Some(6)); + let shared = RemoteMailboxOp::SetType(RemoteKind::Shared) + .apply(&moved) + .unwrap(); + assert_eq!(code(&shared), Some(102)); + assert_eq!( + shared.attributes().type_details, + Some(type_details::REMOTE_SHARED_MAILBOX) + ); + + let user = RemoteMailboxOp::Enable { + kind: RemoteKind::User, + routing: R, + } + .apply(&Recipient::NotMailEnabled) + .unwrap(); + let gone = RemoteMailboxOp::Disable.apply(&user).unwrap(); + assert_eq!(code(&gone), Some(8)); + assert_eq!(gone.attributes().type_details, None); + assert_eq!(gone.attributes().target_address, None); + assert!(!gone.is_recipient()); + } + + // Steps outside the table are refused, never approximated. + #[test] + fn steps_outside_the_table_are_refused() { + use LifecycleRefusal::{NotApplicable, NotInTable}; + let enable = |kind| RemoteMailboxOp::Enable { kind, routing: R }; + // No shared variant of enable; a provisioned shared mailbox is not listed. + assert_eq!( + enable(RemoteKind::Shared).apply(&Recipient::NotMailEnabled), + Err(NotInTable) + ); + let room = enable(RemoteKind::Room) + .apply(&Recipient::NotMailEnabled) + .unwrap(); + assert_eq!( + RemoteMailboxOp::SetType(RemoteKind::Shared).apply(&room), + Err(NotInTable) + ); + // Disable is listed for user mailboxes only. + assert_eq!(RemoteMailboxOp::Disable.apply(&room), Err(NotInTable)); + // Enable twice; enable on an on-premises mailbox; archive twice. + assert_eq!(enable(RemoteKind::User).apply(&room), Err(NotApplicable)); + assert_eq!( + enable(RemoteKind::User).apply(&Recipient::OnPremisesMailbox { + archive: ArchiveState::None + }), + Err(NotApplicable) + ); + let archived = RemoteMailboxOp::EnableArchive.apply(&room).unwrap(); + assert_eq!( + RemoteMailboxOp::EnableArchive.apply(&archived), + Err(NotApplicable) + ); + // Nothing applies to a raw recipient. + let raw = Recipient::Other(attrs(None, Some(6), Some(128))); + assert_eq!(RemoteMailboxOp::Disable.apply(&raw), Err(NotApplicable)); + } + + // Decode is strict: a listed code with the wrong type codes, a remote + // mailbox without its routing address, or an unlisted code stays raw. + #[test] + fn inconsistent_attributes_stay_raw() { + let mut a = attrs(Some(1), Some(-2_147_483_642), Some(2_147_483_648)); + assert!(matches!(Recipient::from_attributes(a), Recipient::Other(_))); + a.target_address = Some(R); + assert!(matches!( + Recipient::from_attributes(a), + Recipient::RemoteMailbox(_) + )); + a.type_details = Some(type_details::REMOTE_SHARED_MAILBOX); + assert_eq!(Recipient::from_attributes(a), Recipient::Other(a)); + let unlisted = attrs(Some(5), None, None); + assert_eq!( + Recipient::from_attributes(unlisted), + Recipient::Other(unlisted) + ); + // A mail user (128) is a recipient this vocabulary keeps raw. + let mail_user = attrs(None, Some(6), Some(128)); + assert!(Recipient::from_attributes(mail_user).is_recipient()); + assert_eq!( + Recipient::from_attributes(RecipientAttributes::default()), + Recipient::NotMailEnabled + ); + } + + // `between` names the one step joining two states, and nothing when no + // single step does. + #[test] + fn between_finds_the_single_step() { + let user = RemoteMailboxOp::Enable { + kind: RemoteKind::User, + routing: R, + } + .apply(&Recipient::NotMailEnabled) + .unwrap(); + assert_eq!( + RemoteMailboxOp::between(&Recipient::NotMailEnabled, &user), + Some(RemoteMailboxOp::Enable { + kind: RemoteKind::User, + routing: R + }) + ); + let archived = RemoteMailboxOp::EnableArchive.apply(&user).unwrap(); + assert_eq!( + RemoteMailboxOp::between(&user, &archived), + Some(RemoteMailboxOp::EnableArchive) + ); + // Two steps apart: enable and archive at once. + assert_eq!( + RemoteMailboxOp::between(&Recipient::NotMailEnabled, &archived), + None + ); + } +} diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index 68049a51..505cc94b 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -16,6 +16,7 @@ //! Design: `docs/DIRECTORY-SIMULATION-POC.md`. pub mod change; +pub mod exchange; pub mod plan; pub mod provenance; pub mod violation; @@ -23,6 +24,10 @@ pub mod violation; pub use change::{ Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize, }; +pub use exchange::{ + ArchiveState, LifecycleRefusal, MailboxState, Recipient, RecipientAttributes, RemoteKind, + RemoteMailbox, RemoteMailboxOp, +}; pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; pub use provenance::{EvidenceRef, Origin, RuleId, TAG_DESIRED, TAG_OBSERVED, Version, VersionId}; pub use violation::{Endpoint, Violation}; diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs index cc83d09d..71547e19 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -7,6 +7,7 @@ //! true → execute; changed → re-observe and re-plan). use crate::change::{Attribute, Change, KeyId, NodeState, ValueId}; +use crate::exchange::{Recipient, RemoteMailboxOp}; use crate::provenance::VersionId; use ogar_dir_core::{Dn128, Guid128}; use std::collections::{BTreeMap, BTreeSet}; @@ -14,7 +15,8 @@ use std::collections::{BTreeMap, BTreeSet}; /// A technology-neutral directory operation. /// /// Variant order is load-bearing and mirrors [`Change`]: removals, deletes, -/// attribute sets, enable/disable, moves, creates, adds. A sorted plan is therefore a safe +/// attribute sets, enable/disable, moves, recipient lifecycle steps, +/// creates, adds. A sorted plan is therefore a safe /// execution order (an address is freed before it is claimed; a node's /// edges are gone before it is, and it exists before it gains one). #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -57,6 +59,13 @@ pub enum Operation { /// Destination. to: Dn128, }, + /// One step of the Exchange hybrid remote-mailbox lifecycle. + RemoteMailbox { + /// Object. + object: Guid128, + /// The step. + op: RemoteMailboxOp, + }, /// Create `object` with `state`. CreateObject { /// Object. @@ -86,6 +95,8 @@ pub enum Precondition { EnabledEquals(Option), /// The object must still be at this location (`None`: still unknown). LocationEquals(Option), + /// The object must still have exactly this recipient role. + RecipientEquals(Recipient), /// No object with this identity may exist yet. ObjectAbsent, /// The object must still exist in exactly this state and have no @@ -105,7 +116,8 @@ pub struct PlannedOp { impl PlannedOp { /// Lower one change. `Err(node)` when the change has no operation: its - /// target is an unknown flag or location, which no actuator can write. + /// target is an unknown flag or location, which no actuator can write, + /// or a recipient change that no single lifecycle step performs. /// /// # Errors /// @@ -126,6 +138,18 @@ impl PlannedOp { }, precondition: Precondition::LocationEquals(from), }, + Change::SetRecipient { node, from, to } => { + // Unknown on either side, or no single lifecycle step: no + // operation. + let (Some(from), Some(to)) = (from, to) else { + return Err(node); + }; + let op = RemoteMailboxOp::between(&from, &to).ok_or(node)?; + Self { + op: Operation::RemoteMailbox { object: node, op }, + precondition: Precondition::RecipientEquals(from), + } + } Change::CreateNode { node, state } => Self { op: Operation::CreateObject { object: node, @@ -303,8 +327,9 @@ pub enum PlanError { node: Guid128, }, /// The desired version changes `node` towards an unknown enabled flag or - /// location. "Unknown" is an observation, not an intention: no actuator - /// can write it, so no plan is derived. + /// location ("unknown" is an observation, not an intention), or changes + /// its recipient role by something other than one lifecycle step. No + /// actuator can perform it, so no plan is derived. NotActuatable { /// The observation. basis: VersionId, @@ -356,6 +381,7 @@ mod tests { upn: None, primary_smtp: None, dn: None, + recipient: None, } } @@ -563,6 +589,54 @@ mod tests { } } + // A recipient change lowers to the single lifecycle step joining its + // two states, preconditioned on the observed role; unknown on either + // side, or two steps at once, is not actuatable. + #[test] + fn a_recipient_change_lowers_to_one_lifecycle_step() { + use crate::exchange::{RemoteKind, RemoteMailboxOp as Op}; + let g = Guid128([6; 16]); + let routing = ValueId(42); + let enabled = Op::Enable { + kind: RemoteKind::User, + routing, + } + .apply(&Recipient::NotMailEnabled) + .unwrap(); + let set = |from, to| Change::SetRecipient { node: g, from, to }; + let plan = ExecutionPlan::from_diff( + VersionId(0), + VersionId(1), + vec![set(Some(Recipient::NotMailEnabled), Some(enabled))], + key, + ) + .unwrap(); + assert_eq!( + plan.ops, + vec![PlannedOp { + op: Operation::RemoteMailbox { + object: g, + op: Op::Enable { + kind: RemoteKind::User, + routing + } + }, + precondition: Precondition::RecipientEquals(Recipient::NotMailEnabled), + }] + ); + let archived = Op::EnableArchive.apply(&enabled).unwrap(); + for c in [ + set(Some(Recipient::NotMailEnabled), Some(archived)), + set(None, Some(enabled)), + set(Some(enabled), None), + ] { + assert!(matches!( + ExecutionPlan::from_diff(VersionId(0), VersionId(1), vec![c], key), + Err(PlanError::NotActuatable { node, .. }) if node == g + )); + } + } + #[test] fn lowering_carries_the_basis_precondition_and_no_transport() { let g = |n| Guid128([n; 16]); diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index a203def1..ebe86991 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -211,5 +211,27 @@ no-population-intermediate rule. separate node from its AD source (`sync_edges` links them as evidence), and no observation path merges the two, so today each node carries its one source's flag. +- **V6 — Exchange hybrid recipients (2026-10-07).** What an object is to + Exchange comes from `msExchRemoteRecipientType` (flags: provision / + migrated / deprovision mailbox, provision / deprovision archive, room, + equipment, both = shared), `msExchRecipientDisplayType`, + `msExchRecipientTypeDetails` and `targetAddress` — not from the account's + enabled flag: a shared, room or equipment mailbox is a disabled account + and still a recipient. `ogar-ad` schema v2 ingests the triplet raw (type + details as its 64-bit decimal text). `ogar_dir_sim::exchange::Recipient` + decodes it strictly: exactly the 26 remote-recipient-type values the + hybrid lifecycle produces (with the display and type codes that go with + them, and the routing address while the mailbox exists), an on-premises + mailbox with its remote-archive state, not mail-enabled — anything else + stays raw (`Other`), never guessed. `RemoteMailboxOp` is the lifecycle: + `Enable` (user / room / equipment, with routing address), `EnableArchive`, + `DisableArchive`, `CompleteMove`, `SetType`, `Disable`; each step refuses + outside the table. `NodeState::recipient` (`None` = not read, distinct + from not mail-enabled) changes by compare-and-set (`SetRecipient`); a plan + lowers it to the single lifecycle step joining the two roles + (`Operation::RemoteMailbox`, precondition `RecipientEquals`), or refuses + (`NotActuatable`) when no single step does. **Open:** the `smtp:` routing + proxy `Enable-RemoteMailbox` stamps is not added to the proxy relation by + the vocabulary; the executor derives ownership of the routing address. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first. From 2ca96b2d8d8c4b66c6d0af5a491a21a4f428bac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:28:41 +0000 Subject: [PATCH 2/8] dir-sim exchange: drop the redundant Enable-Shared guard; the table already refuses code 97 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/exchange.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs index 5c4c7d1a..5ddde93c 100644 --- a/crates/ogar-dir-sim/src/exchange.rs +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -383,17 +383,14 @@ impl RemoteMailboxOp { }; let live = |m: &RemoteMailbox| m.mailbox != MailboxState::Deprovisioned; match (self, from) { - (Self::Enable { kind, routing }, Recipient::NotMailEnabled) => { - if kind == RemoteKind::Shared { - return Err(NotInTable); - } - remote( - kind, - MailboxState::Provisioned, - ArchiveState::None, - Some(routing), - ) - } + // A shared mailbox has no "provisioned" code (97 is not listed), + // so the table refuses it; no separate check. + (Self::Enable { kind, routing }, Recipient::NotMailEnabled) => remote( + kind, + MailboxState::Provisioned, + ArchiveState::None, + Some(routing), + ), (Self::EnableArchive, Recipient::RemoteMailbox(m)) if live(m) && m.archive != ArchiveState::Provisioned => { From 183055c03ef66d94dd97b8ab70c15189807a7cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:30:21 +0000 Subject: [PATCH 3/8] dir-sim exchange: pin that the lifecycle ops are unambiguous and reach every listed code between() returns the unique step; the uniqueness check was unreachable with the current op set. An exhaustive walk now pins both properties, so an op added later that makes two steps coincide fails a test instead of silently returning None. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/exchange.rs | 64 +++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs index 5ddde93c..7a0978ec 100644 --- a/crates/ogar-dir-sim/src/exchange.rs +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -438,23 +438,28 @@ impl RemoteMailboxOp { Recipient::RemoteMailbox(m) => m.routing, _ => None, }; + let mut hits = Self::candidates(routing) + .into_iter() + .filter(|op| op.apply(from).as_ref() == Ok(to)); + let first = hits.next()?; + hits.next().is_none().then_some(first) + } + + /// Every op that could produce a recipient routed at `routing`. + fn candidates(routing: Option) -> Vec { let kinds = [ RemoteKind::User, RemoteKind::Room, RemoteKind::Equipment, RemoteKind::Shared, ]; - let mut candidates = vec![Self::EnableArchive, Self::DisableArchive, Self::Disable]; - candidates.extend(kinds.map(Self::SetType)); + let mut c = vec![Self::EnableArchive, Self::DisableArchive, Self::Disable]; + c.extend(kinds.map(Self::SetType)); if let Some(routing) = routing { - candidates.extend(kinds.map(|kind| Self::Enable { kind, routing })); - candidates.push(Self::CompleteMove { routing }); + c.extend(kinds.map(|kind| Self::Enable { kind, routing })); + c.push(Self::CompleteMove { routing }); } - let mut hits = candidates - .into_iter() - .filter(|op| op.apply(from).as_ref() == Ok(to)); - let first = hits.next()?; - hits.next().is_none().then_some(first) + c } } @@ -659,6 +664,47 @@ mod tests { // `between` names the one step joining two states, and nothing when no // single step does. + #[test] + fn no_two_ops_reach_the_same_recipient() { + // Walk every recipient reachable from "not mail-enabled" or an + // on-premises mailbox, and check that each (from, to) pair has at + // most one op. `between` relies on this; an op added later that + // breaks it fails here instead of silently returning None. + let mut seen = vec![Recipient::NotMailEnabled]; + seen.extend( + [ + ArchiveState::None, + ArchiveState::Provisioned, + ArchiveState::Deprovisioned, + ] + .map(|archive| Recipient::OnPremisesMailbox { archive }), + ); + let mut i = 0; + while i < seen.len() { + let from = seen[i]; + let mut reached: Vec = Vec::new(); + for op in RemoteMailboxOp::candidates(Some(R)) { + if let Ok(to) = op.apply(&from) { + assert!(!reached.contains(&to), "{from:?} -> {to:?} twice"); + reached.push(to); + if !seen.contains(&to) { + seen.push(to); + } + } + } + i += 1; + } + // Every listed code is reachable: the remote ones as remote + // mailboxes, 2 and 16 as on-premises mailboxes with a cloud archive. + let mut codes: Vec = seen + .iter() + .filter_map(|r| r.attributes().remote_recipient_type) + .collect(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes, REMOTE_RECIPIENT_TYPES.to_vec()); + } + #[test] fn between_finds_the_single_step() { let user = RemoteMailboxOp::Enable { From 5e64d54a62d370b46e8df3bda186f151083cd7d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:35:23 +0000 Subject: [PATCH 4/8] dir-sim: hybrid identity anchors, and 97 = provisioned shared mailbox ogar-ad schema v3 ingests mS-DS-ConsistencyGuid (source anchor, base64 = ImmutableId) and msDS-ExternalDirectoryObjectId (Entra Connect backsync, User_) raw; mailNickname (= the Exchange Online Alias) was already ingested. ogar-dir-sim::identity decodes both to Guid128. The User_/Group_ prefix is a CloudLabel rendered on egress, not a stored string, so the two ids compare as 128-bit ids. Decoding is strict: anything that does not render back to exactly the observed value stays raw. exchange: 97 (64 + 32 + 1, what New-RemoteMailbox -Shared writes) joins the code table, which the source script does not list. A provisioned room may now become shared (33 -> 97); an archived one still may not (35 -> 99). ogar-dir-core::base64 gains encode for the ImmutableId. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 20 +++- crates/ogar-ad/tests/main.rs | 24 +++++ crates/ogar-dir-core/src/base64.rs | 47 ++++++++- crates/ogar-dir-sim/src/exchange.rs | 38 ++++--- crates/ogar-dir-sim/src/identity.rs | 158 ++++++++++++++++++++++++++++ crates/ogar-dir-sim/src/lib.rs | 2 + docs/DIRECTORY-SIMULATION-POC.md | 17 ++- 7 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 crates/ogar-dir-sim/src/identity.rs diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index 475bf885..3836366f 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -23,7 +23,7 @@ use ogar_dir_core::{ }; /// Encoder schema version understood by this crate. -pub const SCHEMA_VERSION: u16 = 2; +pub const SCHEMA_VERSION: u16 = 3; /// This crate's schema id. pub const SCHEMA: SchemaId = SchemaId { family: SchemaFamily::AdDs, @@ -47,6 +47,12 @@ pub const SCHEMA: SchemaId = SchemaId { /// every signed LDAP integer here) and `msExchRecipientTypeDetails` (64-bit, /// so kept as its LDAP decimal text in a pooled slot). Other `msExch*` /// attributes stay out until a consumer needs them. +/// +/// v3 adds the hybrid identity anchors, raw: `mS-DS-ConsistencyGuid` (16 +/// bytes, the source anchor whose base64 is the cloud `ImmutableId`) and +/// `msDS-ExternalDirectoryObjectId` (written back by Entra Connect as +/// `User_`). Decoding both into 128-bit ids is +/// `ogar-dir-sim::identity`'s job, not this crate's. pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -156,6 +162,18 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::Str, since: 2, }, + AttrDef { + name: "mS-DS-ConsistencyGuid", + slot: 15, + kind: AttrKind::Bytes, + since: 3, + }, + AttrDef { + name: "msDS-ExternalDirectoryObjectId", + slot: 16, + kind: AttrKind::Str, + since: 3, + }, ]; /// AD object kinds (family-scoped codes in `object_kind`). diff --git a/crates/ogar-ad/tests/main.rs b/crates/ogar-ad/tests/main.rs index 4fc4553d..375065ba 100644 --- a/crates/ogar-ad/tests/main.rs +++ b/crates/ogar-ad/tests/main.rs @@ -251,3 +251,27 @@ fn exchange_recipient_attributes_are_kept_raw() { // The account is disabled: a shared mailbox is a disabled account. assert_eq!(r.num(0), Some(514)); } + +#[test] +fn hybrid_identity_anchors_are_kept_raw() { + // Entra Connect seeds mS-DS-ConsistencyGuid from objectGUID by default, + // so the same bytes appear twice; the backsync id is plain text. + let text = "dn: CN=Alice,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmailNickname: alice\nmS-DS-ConsistencyGuid:: 4AQlP4lP0xGaDAMF6CwzAQ==\nmsDS-ExternalDirectoryObjectId: User_0b5c3a1e-7d2f-4c88-9e10-3f6a2b4c5d6e\n"; + let e = &ldif::parse(text).unwrap()[0]; + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + let enc = encode(e, domain(), &mut dict, &mut pool, 1).unwrap(); + assert!(enc.ignored.is_empty(), "{:?}", enc.ignored); + let r = enc.record; + let raw = |n: &str| pool.get(r.str_ref(slot(n)).unwrap()).unwrap().to_vec(); + let anchor = raw("mS-DS-ConsistencyGuid"); + assert_eq!(anchor.len(), 16); + assert_eq!( + ogar_dir_core::Guid128::from_ms_bytes(&anchor).unwrap(), + r.node_guid() + ); + assert_eq!( + raw("msDS-ExternalDirectoryObjectId"), + b"User_0b5c3a1e-7d2f-4c88-9e10-3f6a2b4c5d6e" + ); + assert_eq!(raw("mailNickname"), b"alice"); +} diff --git a/crates/ogar-dir-core/src/base64.rs b/crates/ogar-dir-core/src/base64.rs index 6bc1e92d..c8c5654c 100644 --- a/crates/ogar-dir-core/src/base64.rs +++ b/crates/ogar-dir-core/src/base64.rs @@ -1,5 +1,6 @@ -//! Minimal standard-alphabet base64 decoder (LDIF `::` values and Graph -//! `onPremisesImmutableId`). Decode only; padding optional; whitespace rejected. +//! Minimal standard-alphabet base64 (LDIF `::` values and Graph +//! `onPremisesImmutableId`). Decode: padding optional, whitespace rejected. +//! Encode: always padded, the form Graph and Entra Connect print. /// Decode standard base64 (`A-Z a-z 0-9 + /`, optional `=` padding). /// Returns `None` on any invalid character or impossible length. @@ -30,8 +31,50 @@ pub fn decode(s: &str) -> Option> { Some(out) } +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encode as standard, padded base64. +pub fn encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = chunk.len(); + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let v = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]); + for i in 0..4 { + if i <= n { + out.push(ALPHABET[(v >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + #[cfg(test)] mod tests { + #[test] + fn encodes_rfc4648_vectors_and_round_trips() { + for (dec, enc) in [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("fooba", "Zm9vYmE="), + ("foobar", "Zm9vYmFy"), + ] { + assert_eq!(super::encode(dec.as_bytes()), enc); + assert_eq!(super::decode(enc).unwrap(), dec.as_bytes()); + } + let all: Vec = (0..=255).collect(); + assert_eq!(super::decode(&super::encode(&all)).unwrap(), all); + } + #[test] fn decodes_rfc4648_vectors() { for (enc, dec) in [ diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs index 7a0978ec..5a283907 100644 --- a/crates/ogar-dir-sim/src/exchange.rs +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -14,8 +14,10 @@ //! //! The decode accepts exactly the combinations the hybrid lifecycle //! produces (the 26 `msExchRemoteRecipientType` values of -//! `jahube/PShell RemoteMailbox-Archive/Enable-Remotemailbox.PS1`, with the -//! type and display codes that go with them). Anything else is kept raw as +//! `jahube/PShell RemoteMailbox-Archive/Enable-Remotemailbox.PS1`, plus 97 = +//! 64 + 32 + 1, the provisioned shared mailbox that `New-RemoteMailbox +//! -Shared` writes and the script's table does not list; with the type and +//! display codes that go with them). Anything else is kept raw as //! [`Recipient::Other`] and never guessed into a nearby state. //! //! **The enabled flag is not part of this.** A shared, room or equipment @@ -149,12 +151,13 @@ pub enum Recipient { /// The `msExchRemoteRecipientType` values the lifecycle produces. A remote /// mailbox state is valid iff its code is listed; codes 2 and 16 belong to -/// an on-premises mailbox with a remote archive. -pub const REMOTE_RECIPIENT_TYPES: [u32; 26] = [ +/// an on-premises mailbox with a remote archive. Shared is room + equipment +/// (96): 97 provisioned, 100 migrated. +pub const REMOTE_RECIPIENT_TYPES: [u32; 27] = [ 1, 2, 3, 4, 6, 8, 10, 16, 17, 20, 24, // user 33, 35, 36, 38, 49, 52, // room 65, 67, 68, 70, 81, 84, // equipment - 100, 102, 116, // shared + 97, 100, 102, 116, // shared ]; fn archive_bits(a: ArchiveState) -> u32 { @@ -383,8 +386,6 @@ impl RemoteMailboxOp { }; let live = |m: &RemoteMailbox| m.mailbox != MailboxState::Deprovisioned; match (self, from) { - // A shared mailbox has no "provisioned" code (97 is not listed), - // so the table refuses it; no separate check. (Self::Enable { kind, routing }, Recipient::NotMailEnabled) => remote( kind, MailboxState::Provisioned, @@ -478,11 +479,11 @@ mod tests { } } - // Every code 0..=255: exactly the 26 listed codes decode to a lifecycle + // Every code 0..=255: exactly the 27 listed codes decode to a lifecycle // state (with the type codes and routing address that belong to them); // every other code stays raw. Each decoded state re-encodes to its code. #[test] - fn exactly_the_26_listed_remote_recipient_types_decode() { + fn exactly_the_27_listed_remote_recipient_types_decode() { let mut decoded = Vec::new(); for code in 0..=255u32 { let mut hit = None; @@ -603,16 +604,29 @@ mod tests { fn steps_outside_the_table_are_refused() { use LifecycleRefusal::{NotApplicable, NotInTable}; let enable = |kind| RemoteMailboxOp::Enable { kind, routing: R }; - // No shared variant of enable; a provisioned shared mailbox is not listed. + // A provisioned shared mailbox is 97 = 64 + 32 + 1; archiving it + // gives 99, which no lifecycle writes. + let shared = enable(RemoteKind::Shared) + .apply(&Recipient::NotMailEnabled) + .unwrap(); + assert_eq!(shared.attributes().remote_recipient_type, Some(97)); assert_eq!( - enable(RemoteKind::Shared).apply(&Recipient::NotMailEnabled), + RemoteMailboxOp::EnableArchive.apply(&shared), Err(NotInTable) ); let room = enable(RemoteKind::Room) .apply(&Recipient::NotMailEnabled) .unwrap(); + // A provisioned room may become shared (33 -> 97); an archived one + // may not (35 -> 99 is not listed). + assert!( + RemoteMailboxOp::SetType(RemoteKind::Shared) + .apply(&room) + .is_ok() + ); + let archived_room = RemoteMailboxOp::EnableArchive.apply(&room).unwrap(); assert_eq!( - RemoteMailboxOp::SetType(RemoteKind::Shared).apply(&room), + RemoteMailboxOp::SetType(RemoteKind::Shared).apply(&archived_room), Err(NotInTable) ); // Disable is listed for user mailboxes only. diff --git a/crates/ogar-dir-sim/src/identity.rs b/crates/ogar-dir-sim/src/identity.rs new file mode 100644 index 00000000..5fb8c714 --- /dev/null +++ b/crates/ogar-dir-sim/src/identity.rs @@ -0,0 +1,158 @@ +//! Hybrid identity: how an on-premises AD object and its Entra / Exchange +//! Online counterpart name each other. +//! +//! | on-premises (AD) | cloud | +//! |-----------------------------------|------------------------------| +//! | `mS-DS-ConsistencyGuid` (16 bytes) | `ImmutableId` = its base64 | +//! | `msDS-ExternalDirectoryObjectId` | `User_` + the cloud object id | +//! | `mailNickname` | Exchange Online `Alias` | +//! +//! `msDS-ExternalDirectoryObjectId` is written back by Entra Connect. It is +//! decoded into a [`Guid128`] plus a [`CloudLabel`]; the `User_` text is a +//! label pattern rendered on egress, never a stored string. Both ids are +//! therefore 128-bit and compare as ids, not as text. +//! +//! The alias needs no type here: `mailNickname` is ingested raw and is the +//! Exchange Online `Alias` as-is. +//! +//! Decoding is strict, like [`crate::exchange`]: a value that does not +//! re-render to exactly what was observed is not decoded. + +use ogar_dir_core::{Guid128, base64}; + +/// The object-type label Entra Connect puts in front of a cloud object id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum CloudLabel { + /// `User_`. + User, + /// `Group_` (group writeback). + Group, +} + +impl CloudLabel { + /// The text written in front of the id. + pub const fn prefix(self) -> &'static str { + match self { + Self::User => "User_", + Self::Group => "Group_", + } + } + const ALL: [Self; 2] = [Self::User, Self::Group]; +} + +/// A decoded `msDS-ExternalDirectoryObjectId`: the cloud object id, plus the +/// label its text form carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ExternalObjectId { + /// The object-type label. + pub label: CloudLabel, + /// The Entra object id. + pub object_id: Guid128, +} + +impl ExternalObjectId { + /// Decode `User_` / `Group_`. `None` for anything that does + /// not render back to exactly `s` (unknown label, upper-case hex, braces, + /// a nil id); the caller keeps such a value raw. + pub fn parse(s: &str) -> Option { + let (label, rest) = CloudLabel::ALL + .into_iter() + .find_map(|l| s.strip_prefix(l.prefix()).map(|r| (l, r)))?; + let id = Self { + label, + object_id: Guid128::parse(rest).ok()?, + }; + (!id.object_id.is_nil() && id.render() == s).then_some(id) + } + + /// The text form, as Entra Connect writes it. + pub fn render(&self) -> String { + format!("{}{}", self.label.prefix(), self.object_id) + } +} + +/// The source anchor: `mS-DS-ConsistencyGuid`, whose base64 is the cloud +/// `ImmutableId`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SourceAnchor(pub Guid128); + +impl SourceAnchor { + /// From the raw attribute value (16 bytes, Microsoft mixed-endian, the + /// same order as `objectGUID`). `None` for any other length or a nil id. + pub fn from_consistency_guid(raw: &[u8]) -> Option { + let g = Guid128::from_ms_bytes(raw).ok()?; + (!g.is_nil()).then_some(Self(g)) + } + + /// From a cloud `ImmutableId`. Only the padded form this crate renders + /// is accepted, so the decode is exact. + pub fn from_immutable_id(s: &str) -> Option { + let a = Self::from_consistency_guid(&base64::decode(s)?)?; + (a.immutable_id() == s).then_some(a) + } + + /// The raw attribute value. + pub fn to_consistency_guid(&self) -> [u8; 16] { + self.0.to_ms_bytes() + } + + /// The cloud `ImmutableId`. + pub fn immutable_id(&self) -> String { + base64::encode(&self.to_consistency_guid()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "0b5c3a1e-7d2f-4c88-9e10-3f6a2b4c5d6e"; + + #[test] + fn the_label_is_rendered_not_stored() { + let user = ExternalObjectId::parse(&format!("User_{ID}")).unwrap(); + assert_eq!(user.label, CloudLabel::User); + assert_eq!(user.object_id, Guid128::parse(ID).unwrap()); + assert_eq!(user.render(), format!("User_{ID}")); + let group = ExternalObjectId::parse(&format!("Group_{ID}")).unwrap(); + // One id, two labels: the id compares equal, the objects do not. + assert_eq!(group.object_id, user.object_id); + assert_ne!(group, user); + } + + #[test] + fn anything_that_does_not_render_back_stays_raw() { + for bad in [ + ID.to_string(), // no label + format!("user_{ID}"), // label case + format!("Device_{ID}"), // unknown label + format!("User_{}", ID.to_uppercase()), // hex case + format!("User_{{{ID}}}"), // braces + "User_00000000-0000-0000-0000-000000000000".to_string(), + format!("User_{ID} "), + ] { + assert_eq!(ExternalObjectId::parse(&bad), None, "{bad}"); + } + } + + #[test] + fn the_immutable_id_is_the_base64_of_the_consistency_guid() { + // objectGUID 3f2504e0-4f89-11d3-9a0c-0305e82c3301, as LDAP returns it. + let raw = base64::decode("4AQlP4lP0xGaDAMF6CwzAQ==").unwrap(); + let a = SourceAnchor::from_consistency_guid(&raw).unwrap(); + assert_eq!( + a.0, + Guid128::parse("3f2504e0-4f89-11d3-9a0c-0305e82c3301").unwrap() + ); + assert_eq!(a.immutable_id(), "4AQlP4lP0xGaDAMF6CwzAQ=="); + assert_eq!(a.to_consistency_guid().to_vec(), raw); + assert_eq!(SourceAnchor::from_immutable_id(&a.immutable_id()), Some(a)); + // Not 16 bytes, nil, or unpadded: not an anchor. + assert_eq!(SourceAnchor::from_consistency_guid(&raw[..15]), None); + assert_eq!(SourceAnchor::from_consistency_guid(&[0; 16]), None); + assert_eq!( + SourceAnchor::from_immutable_id("4AQlP4lP0xGaDAMF6CwzAQ"), + None + ); + } +} diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index 505cc94b..d292365b 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -17,6 +17,7 @@ pub mod change; pub mod exchange; +pub mod identity; pub mod plan; pub mod provenance; pub mod violation; @@ -28,6 +29,7 @@ pub use exchange::{ ArchiveState, LifecycleRefusal, MailboxState, Recipient, RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, }; +pub use identity::{CloudLabel, ExternalObjectId, SourceAnchor}; pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; pub use provenance::{EvidenceRef, Origin, RuleId, TAG_DESIRED, TAG_OBSERVED, Version, VersionId}; pub use violation::{Endpoint, Violation}; diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index ebe86991..d0e8abbc 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -219,12 +219,12 @@ no-population-intermediate rule. enabled flag: a shared, room or equipment mailbox is a disabled account and still a recipient. `ogar-ad` schema v2 ingests the triplet raw (type details as its 64-bit decimal text). `ogar_dir_sim::exchange::Recipient` - decodes it strictly: exactly the 26 remote-recipient-type values the + decodes it strictly: exactly the 27 remote-recipient-type values (the script's 26 plus 97, provisioned shared) the hybrid lifecycle produces (with the display and type codes that go with them, and the routing address while the mailbox exists), an on-premises mailbox with its remote-archive state, not mail-enabled — anything else stays raw (`Other`), never guessed. `RemoteMailboxOp` is the lifecycle: - `Enable` (user / room / equipment, with routing address), `EnableArchive`, + `Enable` (user / room / equipment / shared, with routing address), `EnableArchive`, `DisableArchive`, `CompleteMove`, `SetType`, `Disable`; each step refuses outside the table. `NodeState::recipient` (`None` = not read, distinct from not mail-enabled) changes by compare-and-set (`SetRecipient`); a plan @@ -233,5 +233,18 @@ no-population-intermediate rule. (`NotActuatable`) when no single step does. **Open:** the `smtp:` routing proxy `Enable-RemoteMailbox` stamps is not added to the proxy relation by the vocabulary; the executor derives ownership of the routing address. +- **V7 — Hybrid identity.** On-premises and cloud name each other by three + attributes, which `ogar-ad` schema v3 ingests raw: `mS-DS-ConsistencyGuid` + (the source anchor; its base64 is the cloud `ImmutableId`), + `msDS-ExternalDirectoryObjectId` (Entra Connect backsync, + `User_`) and `mailNickname` (the Exchange Online + `Alias`, already ingested). `ogar_dir_sim::identity` decodes the two ids + to `Guid128`: `SourceAnchor` (mixed-endian like `objectGUID`, renders the + `ImmutableId`) and `ExternalObjectId { label, object_id }`, where the + `User_` / `Group_` text is a `CloudLabel` rendered on egress, never a + stored string. Decoding is strict: a value that does not render back to + exactly what was observed stays raw. **Open:** no executor ingests them + yet; matching an on-premises node to its cloud object by these ids is the + lance-graph side. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first. From 1666ef89135c2d7ee0913be172b68e8840e24c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:48:13 +0000 Subject: [PATCH 5/8] dir-sim identity: the Entra ObjectId, EXO ExternalDirectoryObjectId and AD msDS-ExternalDirectoryObjectId are one id Only AD's backsync copy carries the User_ label; the two cloud spellings are the bare GUID. Documented and pinned by a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/identity.rs | 28 +++++++++++++++++++++++----- docs/DIRECTORY-SIMULATION-POC.md | 4 +++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/ogar-dir-sim/src/identity.rs b/crates/ogar-dir-sim/src/identity.rs index 5fb8c714..5ce7dc57 100644 --- a/crates/ogar-dir-sim/src/identity.rs +++ b/crates/ogar-dir-sim/src/identity.rs @@ -4,13 +4,17 @@ //! | on-premises (AD) | cloud | //! |-----------------------------------|------------------------------| //! | `mS-DS-ConsistencyGuid` (16 bytes) | `ImmutableId` = its base64 | -//! | `msDS-ExternalDirectoryObjectId` | `User_` + the cloud object id | +//! | `msDS-ExternalDirectoryObjectId` | the cloud object id, `User_`-prefixed | //! | `mailNickname` | Exchange Online `Alias` | //! -//! `msDS-ExternalDirectoryObjectId` is written back by Entra Connect. It is -//! decoded into a [`Guid128`] plus a [`CloudLabel`]; the `User_` text is a -//! label pattern rendered on egress, never a stored string. Both ids are -//! therefore 128-bit and compare as ids, not as text. +//! **One cloud object id, three spellings.** The Entra (formerly MSOL) +//! `ObjectId`, Exchange Online's `ExternalDirectoryObjectId` and AD's +//! `msDS-ExternalDirectoryObjectId` are the same 128-bit id. The two cloud +//! spellings are the bare GUID; only AD's backsync copy carries the `User_` +//! label. So the id is a [`Guid128`] (`ExternalObjectId::object_id`), the +//! cloud spellings parse with [`Guid128::parse`], and the `User_` text is a +//! [`CloudLabel`] rendered on egress to AD — never a stored string. All +//! three compare as ids, not as text. //! //! The alias needs no type here: `mailNickname` is ingested raw and is the //! Exchange Online `Alias` as-is. @@ -120,6 +124,20 @@ mod tests { assert_ne!(group, user); } + #[test] + fn the_three_spellings_are_one_id() { + // Entra ObjectId and Exchange Online ExternalDirectoryObjectId are the + // bare GUID; AD's msDS-ExternalDirectoryObjectId adds the label. + let entra = Guid128::parse(ID).unwrap(); + let exo = Guid128::parse(ID).unwrap(); + let ad = ExternalObjectId::parse(&format!("User_{ID}")).unwrap(); + assert_eq!(ad.object_id, entra); + assert_eq!(ad.object_id, exo); + // The bare cloud spelling is not AD's: it carries no label. + assert_eq!(ExternalObjectId::parse(ID), None); + assert_eq!(ad.object_id.to_string(), ID); + } + #[test] fn anything_that_does_not_render_back_stays_raw() { for bad in [ diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index d0e8abbc..2f7f9eed 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -242,7 +242,9 @@ no-population-intermediate rule. to `Guid128`: `SourceAnchor` (mixed-endian like `objectGUID`, renders the `ImmutableId`) and `ExternalObjectId { label, object_id }`, where the `User_` / `Group_` text is a `CloudLabel` rendered on egress, never a - stored string. Decoding is strict: a value that does not render back to + stored string. The Entra (formerly MSOL) `ObjectId`, Exchange Online's + `ExternalDirectoryObjectId` and AD's `msDS-ExternalDirectoryObjectId` are + one id: the two cloud spellings are the bare GUID, only AD's adds `User_`. Decoding is strict: a value that does not render back to exactly what was observed stays raw. **Open:** no executor ingests them yet; matching an on-premises node to its cloud object by these ids is the lance-graph side. From 0e68f79537fbd6825626b3d319d47a39b756084d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:53:34 +0000 Subject: [PATCH 6/8] dir: store the hybrid identity anchors as 128-bit ids, never as AD strings DirRecord ABI minor 1 gives the first 64 reserved bytes four inline 128-bit id slots (AttrKind::Guid, presence bits 36..40). A minor-0 record has them zero with the bits clear, so it reads as no id. ogar-ad schema v3 now puts mS-DS-ConsistencyGuid and msDS-ExternalDirectoryObjectId there instead of pooled text. In: the anchor's mixed-endian bytes become a Guid128; the User_/Group_ label is stripped and must match the object kind, else the entry is refused (BadId). Out: external_directory_object_id() adds the label back from the kind, consistency_guid() returns the AD bytes. The label codec has one definition, ogar_dir_core::label; ogar-dir-sim's identity module re-exports it instead of keeping a copy. ogar-az stores a Guid attribute from Graph's bare GUID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 88 +++++++++++++++++++++++++--- crates/ogar-ad/tests/main.rs | 89 ++++++++++++++++++++++++----- crates/ogar-az/src/lib.rs | 8 +++ crates/ogar-dir-core/src/label.rs | 82 ++++++++++++++++++++++++++ crates/ogar-dir-core/src/lib.rs | 6 +- crates/ogar-dir-core/src/record.rs | 44 ++++++++++++-- crates/ogar-dir-core/src/schema.rs | 37 +++++++++--- crates/ogar-dir-core/tests/main.rs | 50 ++++++++++++++++ crates/ogar-dir-sim/src/identity.rs | 38 +++--------- docs/DIRECTORY-SIMULATION-POC.md | 27 +++++---- 10 files changed, 390 insertions(+), 79 deletions(-) create mode 100644 crates/ogar-dir-core/src/label.rs diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index 3836366f..02633d1e 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -17,6 +17,7 @@ pub mod ldif; use ogar_dir_core::dn::Dn; +use ogar_dir_core::label::{self, CloudLabel}; use ogar_dir_core::record::{FLAG_DN_UNENCODED, FLAG_NON_OU_CONTAINER}; use ogar_dir_core::{ AttrDef, AttrKind, DirRecord, Guid128, OuDictionary, SchemaFamily, SchemaId, ValuePool, @@ -48,11 +49,13 @@ pub const SCHEMA: SchemaId = SchemaId { /// so kept as its LDAP decimal text in a pooled slot). Other `msExch*` /// attributes stay out until a consumer needs them. /// -/// v3 adds the hybrid identity anchors, raw: `mS-DS-ConsistencyGuid` (16 -/// bytes, the source anchor whose base64 is the cloud `ImmutableId`) and +/// v3 adds the hybrid identity anchors as 128-bit ids in inline guid slots, +/// never as text: `mS-DS-ConsistencyGuid` (16 mixed-endian bytes, the source +/// anchor whose base64 is the cloud `ImmutableId`) and /// `msDS-ExternalDirectoryObjectId` (written back by Entra Connect as -/// `User_`). Decoding both into 128-bit ids is -/// `ogar-dir-sim::identity`'s job, not this crate's. +/// `User_`). The `User_` / `Group_` label is stripped on the +/// way in — it must match the object's kind — and added back on the way out +/// from the kind ([`external_directory_object_id`]); it is never stored. pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -164,14 +167,14 @@ pub const SCHEMA_V1: &[AttrDef] = &[ }, AttrDef { name: "mS-DS-ConsistencyGuid", - slot: 15, - kind: AttrKind::Bytes, + slot: 0, + kind: AttrKind::Guid, since: 3, }, AttrDef { name: "msDS-ExternalDirectoryObjectId", - slot: 16, - kind: AttrKind::Str, + slot: 1, + kind: AttrKind::Guid, since: 3, }, ]; @@ -195,6 +198,20 @@ pub enum AdKind { } impl AdKind { + /// From the record's `object_kind` code. + pub fn from_code(code: u16) -> Option { + [ + Self::Other, + Self::User, + Self::Group, + Self::Computer, + Self::Contact, + Self::OrganizationalUnit, + ] + .into_iter() + .find(|k| *k as u16 == code) + } + /// From `objectClass` values; most specific class wins. pub fn from_object_class>(classes: &[S]) -> Self { let has = |c: &str| classes.iter().any(|x| x.as_ref().eq_ignore_ascii_case(c)); @@ -245,6 +262,9 @@ pub enum AdError { MultipleValues(&'static str), /// A numeric attribute did not parse. BadNumber(&'static str), + /// A 128-bit id attribute was not a non-nil id in its source spelling + /// (16 raw bytes; `