Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions crates/ogar-ad/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use ogar_dir_core::{
};

/// Encoder schema version understood by this crate.
pub const SCHEMA_VERSION: u16 = 5;
pub const SCHEMA_VERSION: u16 = 6;
/// This crate's schema id.
pub const SCHEMA: SchemaId = SchemaId {
family: SchemaFamily::AdDs,
Expand Down Expand Up @@ -64,6 +64,12 @@ pub const SCHEMA: SchemaId = SchemaId {
/// v5 adds `extensionAttribute1`..`15` as one positional bag
/// ([`AttrKind::Bag`]), the shape Graph returns them in
/// (`onPremisesExtensionAttributes`).
///
/// v6 adds `msExchMailboxGuid`, the mailbox's own GUID (16 mixed-endian
/// bytes, like `objectGUID`), in an inline guid slot. Exchange Online keeps
/// it as the mailbox's `ExchangeGuid` across a move, so it is the mailbox
/// half of an object's Exchange identity (`ogar-dir-sim::ExchangeIdentity`)
/// and, like the source anchor, never text.
pub const SCHEMA_V1: &[AttrDef] = &[
AttrDef {
name: "distinguishedName",
Expand Down Expand Up @@ -197,6 +203,12 @@ pub const SCHEMA_V1: &[AttrDef] = &[
kind: AttrKind::Bag,
since: 5,
},
AttrDef {
name: "msExchMailboxGuid",
slot: 2,
kind: AttrKind::Guid,
since: 6,
},
];

/// AD object kinds (family-scoped codes in `object_kind`).
Expand Down Expand Up @@ -319,7 +331,7 @@ pub fn cloud_label(kind: AdKind) -> Option<CloudLabel> {
/// Inbound: a 128-bit id attribute's source spelling → the id.
fn decode_id(name: &str, raw: &[u8], kind: u16) -> Option<Guid128> {
let g = match name {
"mS-DS-ConsistencyGuid" => Guid128::from_ms_bytes(raw).ok()?,
"mS-DS-ConsistencyGuid" | "msExchMailboxGuid" => Guid128::from_ms_bytes(raw).ok()?,
"msDS-ExternalDirectoryObjectId" => {
// The kind's own template: a User_ id on a group does not match.
let label = cloud_label(AdKind::from_code(kind)?)?;
Expand Down Expand Up @@ -351,6 +363,11 @@ pub fn consistency_guid(rec: &DirRecord) -> Option<[u8; 16]> {
Some(rec.guid(guid_slot("mS-DS-ConsistencyGuid"))?.to_ms_bytes())
}

/// Outbound: `msExchMailboxGuid` as AD stores it (16 mixed-endian bytes).
pub fn exchange_guid(rec: &DirRecord) -> Option<[u8; 16]> {
Some(rec.guid(guid_slot("msExchMailboxGuid"))?.to_ms_bytes())
}

/// `base` is a member of a bag attribute (`extensionAttribute7`).
fn is_bag_member(base: &str) -> bool {
SCHEMA_V1
Expand Down
34 changes: 34 additions & 0 deletions crates/ogar-ad/tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,37 @@ fn extension_attributes_are_one_bag() {
assert_eq!(odd.record.str_ref(slot("extensionAttribute")), None);
assert_eq!(odd.ignored, vec!["extensionAttribute01"]);
}

// msExchMailboxGuid is the mailbox's own GUID: stored as a 128-bit id in a
// guid slot (never pooled text) and given back as AD's 16 mixed-endian
// bytes. A value that is not 16 bytes is refused, never stored as text.
#[test]
fn the_exchange_guid_is_an_id_not_a_string() {
const BOX: &str = "Ab5xLqKmTkiZ8gH0cDeFqw==";
let text = format!(
"dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmsExchMailboxGuid:: {BOX}\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 = ogar_dir_core::base64::decode(BOX).unwrap();
assert_eq!(
r.guid(slot("msExchMailboxGuid")),
Some(Guid128::from_ms_bytes(&raw).unwrap())
);
assert_ne!(r.guid(slot("msExchMailboxGuid")), Some(r.node_guid()));
for s in 0..32 {
if let Some(sr) = r.str_ref(s) {
let v = pool.get(sr).unwrap();
assert!(!v.windows(raw.len()).any(|w| w == raw.as_slice()));
}
}
assert_eq!(ogar_ad::exchange_guid(&r).map(|b| b.to_vec()), Some(raw));

let bad = "dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmsExchMailboxGuid:: AAEC\n";
let e = &ldif::parse(bad).unwrap()[0];
let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new());
assert!(encode(e, domain(), &mut dict, &mut pool, 1).is_err());
}
56 changes: 55 additions & 1 deletion crates/ogar-dir-sim/src/exchange.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
//! **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;
use crate::change::{KeyId, ValueId};
use ogar_dir_core::Guid128;

/// `msExchRemoteRecipientType` flag bits.
pub mod flag {
Expand Down Expand Up @@ -334,6 +335,59 @@ impl Recipient {
}
}

/// What Exchange knows an object as, read by its GUID: its recipient types
/// ([`Recipient`]), its mailbox's `ExchangeGuid` (`msExchMailboxGuid`
/// on-premises; Exchange Online keeps the same value as the mailbox's
/// `ExchangeGuid` across a move) and the address it receives at
/// (`PrimarySmtpAddress`).
///
/// The identity is `node` and, for a mailbox, `exchange_guid`; the address is
/// a value of the identity, never the key it is found by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ExchangeIdentity {
/// The object (`objectGUID`).
pub node: Guid128,
/// Recipient types; `None` = the source did not read them.
pub recipient: Option<Recipient>,
/// `msExchMailboxGuid`; `None` = no mailbox, or not read.
pub exchange_guid: Option<Guid128>,
/// `PrimarySmtpAddress`.
pub primary_smtp: Option<ValueId>,
}

/// What an object's `mail` label resolves to. The label is the trigger: its
/// address is looked up once, among the addresses objects actually hold
/// (UPN, `proxyAddresses`, routing address), and the holder's
/// [`ExchangeIdentity`] is hydrated by GUID. The label itself never reserves
/// the address and never decides delivery or access.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MailLabel {
/// The label is an address the labelled object holds itself.
Own(ExchangeIdentity),
/// The label names an address another object holds: an admin account
/// whose `mail` is a user's mailbox (a password-reset target), say. The
/// identity is the holder's.
Elsewhere {
/// The label's comparison key.
key: KeyId,
/// The object that holds the address.
holder: ExchangeIdentity,
},
/// Several objects hold the address: none is hydrated, the holders are
/// listed (sorted).
Contested {
/// The label's comparison key.
key: KeyId,
/// Every holder.
holders: Vec<Guid128>,
},
/// No object holds the address: a stale label.
Unheld {
/// The label's comparison key.
key: KeyId,
},
}

/// 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)]
Expand Down
5 changes: 3 additions & 2 deletions crates/ogar-dir-sim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ pub use change::{
Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize,
};
pub use exchange::{
AddressTemplate, ArchiveState, LifecycleRefusal, MailboxState, ROUTING, Recipient,
RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, routing_parts,
AddressTemplate, ArchiveState, ExchangeIdentity, LifecycleRefusal, MailLabel, MailboxState,
ROUTING, Recipient, RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp,
routing_parts,
};
pub use identity::{CloudLabel, ExternalObjectId, SourceAnchor};
pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition};
Expand Down
15 changes: 10 additions & 5 deletions crates/ogar-dir-sim/src/violation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ pub enum AddressRole {
PrimarySmtp,
/// A secondary SMTP proxy (`smtp:`).
SecondarySmtp,
/// The `mail` attribute.
/// The `mail` attribute: a label, never a claim. Exchange does not
/// reserve it, so it never makes an object a holder in
/// [`Violation::AddressConflict`]. A label is a hydration trigger: it is
/// resolved to the object that does hold the address it names, and that
/// object's [`ExchangeIdentity`](crate::exchange::ExchangeIdentity) is
/// read by GUID ([`MailLabel`](crate::exchange::MailLabel)).
Mail,
/// A remote mailbox's routing address (`targetAddress`).
Routing,
Expand All @@ -49,10 +54,10 @@ pub enum Violation {
owners: Vec<Guid128>,
},
/// Two or more objects hold the same normalized address under different
/// attributes: a UPN or SMTP address that is another object's SMTP,
/// `mail` or routing address, or a `mail` held by another object (an
/// admin account whose `mail` points at someone's mailbox as a
/// password-reset target, for example). Same-attribute collisions are
/// attributes: a UPN or SMTP address that is another object's SMTP or
/// routing address. A `mail` value is not a claim (an admin account
/// whose `mail` points at someone's mailbox as a password-reset target
/// holds nothing), so it never appears here. Same-attribute collisions are
/// [`Violation::DuplicateSmtp`] / [`Violation::DuplicateUpn`]; one object
/// holding an address under several attributes is not a conflict.
AddressConflict {
Expand Down
Loading