From cb00fc60e1ea0205eb5348d0850440e3692dd7f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:43:17 +0000 Subject: [PATCH 1/4] dir-sim: one address space, the routing template, and the AD-cloud sync map exchange: AddressTemplate (numbered placeholders, render and strict match) and ROUTING = {0}@{1}.mail.onmicrosoft.com; routing_parts returns (alias, tenant). The alias is mailNickname. violation: AddressRole and three invariants. AddressConflict: UPN, SMTP, mail and routing addresses share one key space, so a UPN or SMTP address that is another object's SMTP, mail or routing address conflicts, and so does a mail held by another object (an admin account with mail set as a password-reset target). RoutingNotInProxies: a remote mailbox must own its routing address as an SMTP proxy. RoutingMismatch: the routing address must be the template for the node's own alias. ogar-az: SYNCED maps AD attributes to their cloud names, among them physicalDeliveryOfficeName -> officeLocation and userAccountControl -> accountEnabled. ogar-ad schema v4 adds physicalDeliveryOfficeName. No executor: lance-graph computes these violations next. Adding Violation variants does not break it (it only constructs violations). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 11 ++- crates/ogar-ad/tests/main.rs | 16 ++++ crates/ogar-az/Cargo.toml | 3 + crates/ogar-az/src/lib.rs | 93 +++++++++++++++++++++ crates/ogar-az/tests/main.rs | 45 ++++++++++ crates/ogar-dir-sim/src/exchange.rs | 119 +++++++++++++++++++++++++++ crates/ogar-dir-sim/src/lib.rs | 6 +- crates/ogar-dir-sim/src/violation.rs | 45 ++++++++++ docs/DIRECTORY-SIMULATION-POC.md | 23 ++++++ 9 files changed, 357 insertions(+), 4 deletions(-) diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index 29f1caa2..5d9162c9 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -24,7 +24,7 @@ use ogar_dir_core::{ }; /// Encoder schema version understood by this crate. -pub const SCHEMA_VERSION: u16 = 3; +pub const SCHEMA_VERSION: u16 = 4; /// This crate's schema id. pub const SCHEMA: SchemaId = SchemaId { family: SchemaFamily::AdDs, @@ -56,6 +56,9 @@ pub const SCHEMA: SchemaId = SchemaId { /// `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. +/// +/// v4 adds `physicalDeliveryOfficeName`, the attribute Entra Connect syncs +/// to the cloud's `officeLocation` (see `ogar_az::SYNCED`). pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -177,6 +180,12 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::Guid, since: 3, }, + AttrDef { + name: "physicalDeliveryOfficeName", + slot: 15, + kind: AttrKind::Str, + since: 4, + }, ]; /// 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 9d62113e..17a9fa8e 100644 --- a/crates/ogar-ad/tests/main.rs +++ b/crates/ogar-ad/tests/main.rs @@ -334,3 +334,19 @@ fn a_wrong_or_missing_label_is_refused() { assert_eq!(hybrid(class, &value).unwrap_err(), bad, "{class} {value}"); } } + +// v4: the office is kept raw; it becomes the cloud's officeLocation. +#[test] +fn the_office_is_kept_raw() { + let text = "dn: CN=A,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nphysicalDeliveryOfficeName: Berlin 4.12\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; + assert_eq!( + pool.get(r.str_ref(slot("physicalDeliveryOfficeName")).unwrap()) + .unwrap(), + b"Berlin 4.12" + ); +} diff --git a/crates/ogar-az/Cargo.toml b/crates/ogar-az/Cargo.toml index c4f7cde2..f6fa60b7 100644 --- a/crates/ogar-az/Cargo.toml +++ b/crates/ogar-az/Cargo.toml @@ -11,3 +11,6 @@ description = "Microsoft Entra ID / Microsoft Graph source-native schema for OGA [dependencies] ogar-dir-core = { path = "../ogar-dir-core" } serde_json = "1.0" + +[dev-dependencies] +ogar-ad = { path = "../ogar-ad" } diff --git a/crates/ogar-az/src/lib.rs b/crates/ogar-az/src/lib.rs index 2d76368e..efdc3b16 100644 --- a/crates/ogar-az/src/lib.rs +++ b/crates/ogar-az/src/lib.rs @@ -165,6 +165,99 @@ pub const SCHEMA_V1: &[AttrDef] = &[ }, ]; +/// How an AD value becomes its cloud counterpart under Entra Connect sync. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SyncTransform { + /// The same value. + Same, + /// `accountEnabled` is `userAccountControl` without bit `0x2` + /// (ACCOUNTDISABLE); how the two flags combine is + /// `ogar_dir_sim::effective_active`. + AccountEnabled, + /// `onPremisesImmutableId` is the base64 of the 16 + /// `mS-DS-ConsistencyGuid` bytes. + ImmutableId, + /// The cloud value is the AD value prefixed `onPremises*` evidence + /// (DN, sAMAccountName, SID): copied, not mapped to a cloud property. + OnPremisesEvidence, +} + +/// One attribute AD and the cloud both hold, under their own names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SyncedAttribute { + /// The AD attribute (`ogar_ad::SCHEMA_V1`). + pub ad: &'static str, + /// The Graph property ([`SCHEMA_V1`]). + pub cloud: &'static str, + /// How the value travels. + pub transform: SyncTransform, +} + +/// The AD → cloud correspondence for the attributes both schemas ingest. +/// The cloud object id itself is not here: it is the record's identity, and +/// AD's `msDS-ExternalDirectoryObjectId` is that id with its `User_` label +/// (`ogar_dir_core::label`). +pub const SYNCED: &[SyncedAttribute] = &[ + synced( + "userPrincipalName", + "userPrincipalName", + SyncTransform::Same, + ), + synced("displayName", "displayName", SyncTransform::Same), + synced("givenName", "givenName", SyncTransform::Same), + synced("sn", "surname", SyncTransform::Same), + synced("mail", "mail", SyncTransform::Same), + synced("mailNickname", "mailNickname", SyncTransform::Same), + synced("proxyAddresses", "proxyAddresses", SyncTransform::Same), + synced( + "physicalDeliveryOfficeName", + "officeLocation", + SyncTransform::Same, + ), + synced( + "userAccountControl", + "accountEnabled", + SyncTransform::AccountEnabled, + ), + synced( + "mS-DS-ConsistencyGuid", + "onPremisesImmutableId", + SyncTransform::ImmutableId, + ), + synced( + "distinguishedName", + "onPremisesDistinguishedName", + SyncTransform::OnPremisesEvidence, + ), + synced( + "sAMAccountName", + "onPremisesSamAccountName", + SyncTransform::OnPremisesEvidence, + ), + synced( + "objectSid", + "onPremisesSecurityIdentifier", + SyncTransform::OnPremisesEvidence, + ), +]; + +const fn synced( + ad: &'static str, + cloud: &'static str, + transform: SyncTransform, +) -> SyncedAttribute { + SyncedAttribute { + ad, + cloud, + transform, + } +} + +/// The cloud counterpart of an AD attribute, if it is synced. +pub fn cloud_of(ad: &str) -> Option<&'static SyncedAttribute> { + SYNCED.iter().find(|s| s.ad.eq_ignore_ascii_case(ad)) +} + /// AZ object kinds (family-scoped codes). Only users are ingested in v1. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u16)] diff --git a/crates/ogar-az/tests/main.rs b/crates/ogar-az/tests/main.rs index 08b9b29a..ee6b9aa8 100644 --- a/crates/ogar-az/tests/main.rs +++ b/crates/ogar-az/tests/main.rs @@ -141,3 +141,48 @@ fn attr_str_refuses_multi_values_and_attr_multi_reads_them() { ); assert_eq!(ogar_az::attr_multi(&page.records[0], &pool, "mail"), None); } + +// Every synced pair names an attribute each schema really ingests, and each +// side is mapped once — so a rename on either side fails here. +#[test] +fn every_synced_attribute_exists_on_both_sides_once() { + use ogar_az::{SYNCED, SyncTransform, cloud_of}; + for s in SYNCED { + assert!( + ogar_ad::SCHEMA_V1.iter().any(|d| d.name == s.ad), + "AD {}", + s.ad + ); + assert!( + ogar_az::SCHEMA_V1.iter().any(|d| d.name == s.cloud), + "cloud {}", + s.cloud + ); + assert_eq!( + SYNCED.iter().filter(|o| o.ad == s.ad).count(), + 1, + "{}", + s.ad + ); + assert_eq!( + SYNCED.iter().filter(|o| o.cloud == s.cloud).count(), + 1, + "{}", + s.cloud + ); + } + let office = cloud_of("physicalDeliveryOfficeName").unwrap(); + assert_eq!( + (office.cloud, office.transform), + ("officeLocation", SyncTransform::Same) + ); + assert_eq!( + cloud_of("userAccountControl").unwrap().cloud, + "accountEnabled" + ); + assert_eq!( + cloud_of("targetAddress"), + None, + "the routing address is not synced" + ); +} diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs index 5a283907..73d8aa7e 100644 --- a/crates/ogar-dir-sim/src/exchange.rs +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -464,6 +464,90 @@ impl RemoteMailboxOp { } } +/// A string template with numbered placeholders `{0}`, `{1}`, … in order — +/// the multi-value sibling of `ogar_dir_core::label::LabelPattern`. It +/// renders by substitution and matches by splitting on the literal text +/// between the placeholders; a match must render back to exactly the input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AddressTemplate(pub &'static str); + +impl AddressTemplate { + /// The literal text around the placeholders: one more literal than + /// placeholders. `None` unless `{0}`, `{1}`, … each appear once, in + /// order, separated by non-empty literals (else a match is ambiguous). + fn literals(self) -> Option> { + let mut lits = Vec::new(); + let mut rest = self.0; + let mut n = 0; + while let Some(at) = rest.find('{') { + let tag = format!("{{{n}}}"); + if !rest[at..].starts_with(&tag) { + return None; + } + lits.push(&rest[..at]); + rest = &rest[at + tag.len()..]; + n += 1; + } + lits.push(rest); + (n > 0 && lits[1..lits.len() - 1].iter().all(|l| !l.is_empty())).then_some(lits) + } + + /// The template with each `{i}` replaced by `values[i]`. `None` if the + /// number of values does not match. + pub fn render(self, values: &[&str]) -> Option { + let lits = self.literals()?; + (values.len() == lits.len() - 1).then(|| { + let mut out = lits[0].to_string(); + for (v, l) in values.iter().zip(&lits[1..]) { + out.push_str(v); + out.push_str(l); + } + out + }) + } + + /// The placeholder values of `s`, if `s` is this template with + /// non-empty values that render back to exactly `s`. + pub fn matches(self, s: &str) -> Option> { + let lits = self.literals()?; + let mut rest = s.strip_prefix(lits[0])?; + let mut values = Vec::new(); + for (i, next) in lits[1..].iter().enumerate() { + let value = if i == lits.len() - 2 { + let v = rest.strip_suffix(next)?; + rest = ""; + v + } else { + let at = rest.find(next)?; + let v = &rest[..at]; + rest = &rest[at + next.len()..]; + v + }; + if value.is_empty() { + return None; + } + values.push(value); + } + (rest.is_empty() && self.render(&values).as_deref() == Some(s)).then_some(values) + } +} + +/// A remote mailbox's routing address: `{alias}@{tenant}.mail.onmicrosoft.com`, +/// where the alias is the object's `mailNickname` (the Exchange Online +/// `Alias`). In AD it is `targetAddress` with the `SMTP:` prefix and also one +/// of the `proxyAddresses` (`smtp:`); Exchange Online lists it among +/// `EmailAddresses`. Matched against the normalized (lower-case) address. +pub const ROUTING: AddressTemplate = AddressTemplate("{0}@{1}.mail.onmicrosoft.com"); + +/// `(alias, tenant)` of a routing address, or `None` if it is not one. +pub fn routing_parts(address: &str) -> Option<(&str, &str)> { + match ROUTING.matches(address)?.as_slice() { + // One `@` in the whole address, and the tenant is a single label. + [alias, tenant] if !tenant.contains(['@', '.']) => Some((alias, tenant)), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -482,6 +566,41 @@ mod tests { // 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 the_routing_template_renders_and_matches_alias_and_tenant() { + assert_eq!( + ROUTING.render(&["alice", "contoso"]).as_deref(), + Some("alice@contoso.mail.onmicrosoft.com") + ); + assert_eq!( + routing_parts("alice@contoso.mail.onmicrosoft.com"), + Some(("alice", "contoso")) + ); + for bad in [ + "alice@contoso.onmicrosoft.com", // not the routing domain + "alice@contoso.mail.onmicrosoft.co", // suffix + "@contoso.mail.onmicrosoft.com", // empty alias + "alice@.mail.onmicrosoft.com", // empty tenant + "alice@sub.contoso.mail.onmicrosoft.com", // tenant with a dot + "a@b@contoso.mail.onmicrosoft.com", // alias with an @ + "alice@contoso.mail.onmicrosoft.com.x", + ] { + assert_eq!(routing_parts(bad), None, "{bad}"); + } + } + + #[test] + fn a_template_needs_numbered_placeholders_in_order_with_separators() { + for t in ["{1}@{0}", "{0}{1}", "{0}@{0}", "no placeholder", "{x}"] { + assert_eq!(AddressTemplate(t).render(&["a", "b"]), None, "{t}"); + assert_eq!(AddressTemplate(t).matches("a@b"), None, "{t}"); + } + let t = AddressTemplate("<{0}|{1}>"); + assert_eq!(t.matches(""), Some(vec!["a", "b"])); + assert_eq!(t.render(&["a"]), None, "value count"); + assert_eq!(t.matches("x"), None); + } + #[test] fn exactly_the_27_listed_remote_recipient_types_decode() { let mut decoded = Vec::new(); diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index d292365b..08d129fb 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -26,10 +26,10 @@ pub use change::{ Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize, }; pub use exchange::{ - ArchiveState, LifecycleRefusal, MailboxState, Recipient, RecipientAttributes, RemoteKind, - RemoteMailbox, RemoteMailboxOp, + AddressTemplate, ArchiveState, LifecycleRefusal, MailboxState, ROUTING, Recipient, + RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, routing_parts, }; 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}; +pub use violation::{AddressRole, Endpoint, Violation}; diff --git a/crates/ogar-dir-sim/src/violation.rs b/crates/ogar-dir-sim/src/violation.rs index 5bc0ebf9..22a2ef42 100644 --- a/crates/ogar-dir-sim/src/violation.rs +++ b/crates/ogar-dir-sim/src/violation.rs @@ -14,6 +14,23 @@ pub enum Endpoint { Group, } +/// Under which attribute a node holds an address. One address space spans +/// all of them: Exchange refuses an address another object already holds, +/// whichever attribute holds it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AddressRole { + /// `userPrincipalName`. + Upn, + /// The primary SMTP address (`SMTP:` / `PrimarySmtpAddress`). + PrimarySmtp, + /// A secondary SMTP proxy (`smtp:`). + SecondarySmtp, + /// The `mail` attribute. + Mail, + /// A remote mailbox's routing address (`targetAddress`). + Routing, +} + /// One invariant violation. `Ord` is total; a validator returns a sorted list. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Violation { @@ -31,6 +48,34 @@ pub enum Violation { /// Every owner, sorted. owners: Vec, }, + /// 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 + /// [`Violation::DuplicateSmtp`] / [`Violation::DuplicateUpn`]; one object + /// holding an address under several attributes is not a conflict. + AddressConflict { + /// Comparison key of the address. + key: KeyId, + /// Every holder with the attribute it holds the address under, + /// sorted. + holders: Vec<(Guid128, AddressRole)>, + }, + /// A remote mailbox whose routing address is not one of its own SMTP + /// proxies: Exchange routes to `targetAddress`, and the object must also + /// own it (`smtp:{alias}@{tenant}.mail.onmicrosoft.com`). + RoutingNotInProxies { + /// The mailbox. + node: Guid128, + }, + /// A remote mailbox whose routing address is not + /// `{alias}@{tenant}.mail.onmicrosoft.com` for its own `mailNickname` + /// (see [`crate::exchange::ROUTING`]). + RoutingMismatch { + /// The mailbox. + node: Guid128, + }, /// A membership edge references a missing or wrong-kind endpoint. DanglingMembership { /// Member side as recorded. diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index be714ca1..23c56450 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -253,5 +253,28 @@ no-population-intermediate rule. re-exports. **Open:** no executor ingests them yet; matching an on-premises node to its cloud object by these ids is the lance-graph side. +- **V8 — Addresses are one space; the routing address is the alias.** + AD holds a remote mailbox's addresses as `proxyAddresses` + (`SMTP:{primary}`, `smtp:{secondary}`, + `smtp:{alias}@{tenant}.mail.onmicrosoft.com`) plus `targetAddress` + (`SMTP:{alias}@{tenant}.mail.onmicrosoft.com`); Exchange Online shows the + same set as `PrimarySmtpAddress` and `EmailAddresses`. The alias is + `mailNickname`. Vocabulary: `exchange::ROUTING` is the template + `{0}@{1}.mail.onmicrosoft.com` (`AddressTemplate`, render / strict match); + `routing_parts` returns `(alias, tenant)`. Three invariants, as + `Violation`s: `RoutingMismatch` (the routing address is not the template + for the node's own alias), `RoutingNotInProxies` (the mailbox does not + own its routing address as an SMTP proxy), and `AddressConflict` — UPN, + SMTP, `mail` and routing addresses share one key space, so a UPN or SMTP + address that is another object's SMTP, `mail` or routing address + conflicts, and so does a `mail` held by another object (an admin account + with `mail` set as a password-reset target but no mailbox). Each holder + carries its `AddressRole`; same-attribute collisions stay + `DuplicateSmtp` / `DuplicateUpn`. Sync: `ogar_az::SYNCED` maps the AD + attributes to their cloud names (`physicalDeliveryOfficeName` → + `officeLocation`, `userAccountControl` → `accountEnabled` via + `effective_active`, `mS-DS-ConsistencyGuid` → `onPremisesImmutableId`); + `ogar-ad` schema v4 adds `physicalDeliveryOfficeName`. **Open:** the + executor (lance-graph) does not yet compute these three violations. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first. From 56077ffd7e7ddf6216d0646c9f15fa37a5f93460 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:43:41 +0000 Subject: [PATCH 2/4] dir-sim exchange: drop the template's render-back check; it holds by construction A match strips the prefix, cuts at each separator and strips the suffix, so re-rendering its values always reproduces the input. The disable run showed the check could not fire. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/exchange.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs index 73d8aa7e..d6ba59fa 100644 --- a/crates/ogar-dir-sim/src/exchange.rs +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -467,7 +467,7 @@ impl RemoteMailboxOp { /// A string template with numbered placeholders `{0}`, `{1}`, … in order — /// the multi-value sibling of `ogar_dir_core::label::LabelPattern`. It /// renders by substitution and matches by splitting on the literal text -/// between the placeholders; a match must render back to exactly the input. +/// between the placeholders; a match renders back to exactly the input. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct AddressTemplate(pub &'static str); @@ -507,16 +507,17 @@ impl AddressTemplate { } /// The placeholder values of `s`, if `s` is this template with - /// non-empty values that render back to exactly `s`. + /// non-empty values. They render back to exactly `s` by construction: + /// the match strips the prefix, cuts at each separator and strips the + /// suffix, so nothing is skipped or rewritten. pub fn matches(self, s: &str) -> Option> { let lits = self.literals()?; let mut rest = s.strip_prefix(lits[0])?; + let last = lits.len() - 2; let mut values = Vec::new(); for (i, next) in lits[1..].iter().enumerate() { - let value = if i == lits.len() - 2 { - let v = rest.strip_suffix(next)?; - rest = ""; - v + let value = if i == last { + rest.strip_suffix(next)? } else { let at = rest.find(next)?; let v = &rest[..at]; @@ -528,7 +529,7 @@ impl AddressTemplate { } values.push(value); } - (rest.is_empty() && self.render(&values).as_deref() == Some(s)).then_some(values) + Some(values) } } From 1ba92387a54c8334c964212e17cc1bf9604802d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:55:31 +0000 Subject: [PATCH 3/4] Model onPremisesExtensionAttributes as one positional bag Graph returns onPremisesExtensionAttributes as an object of fifteen extensionAttributeN keys; AD holds them as fifteen separate attributes. Both now land in one pooled slot of kind AttrKind::Bag: member n is extensionAttribute(n+1), an empty member is absent, and a bag with no values sets no slot. The member names are never stored, so AD and Graph produce byte-identical bags and the pair syncs Same. - ogar-dir-core: AttrKind::Bag, BAG_LEN = 15, ValuePool::bag_member - ogar-ad v5: extensionAttribute (slot 16, Bag); members 1..15 are no longer reported as ignored; a multi-valued member is refused - ogar-az v2: onPremisesExtensionAttributes (slot 18, Bag), added to SYNCED - select_is_derived_from_the_schema now checks each version against the attributes that existed by then Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 51 +++++++++++++++++-- crates/ogar-ad/tests/main.rs | 27 ++++++++++ crates/ogar-az/src/lib.rs | 31 +++++++++++- crates/ogar-az/tests/main.rs | 81 +++++++++++++++++++++++++++--- crates/ogar-dir-core/src/lib.rs | 2 +- crates/ogar-dir-core/src/pool.rs | 7 +++ crates/ogar-dir-core/src/schema.rs | 11 +++- 7 files changed, 197 insertions(+), 13 deletions(-) diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index 5d9162c9..996aaf38 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -20,11 +20,11 @@ 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, + AttrDef, AttrKind, BAG_LEN, DirRecord, Guid128, OuDictionary, SchemaFamily, SchemaId, ValuePool, }; /// Encoder schema version understood by this crate. -pub const SCHEMA_VERSION: u16 = 4; +pub const SCHEMA_VERSION: u16 = 5; /// This crate's schema id. pub const SCHEMA: SchemaId = SchemaId { family: SchemaFamily::AdDs, @@ -59,6 +59,10 @@ pub const SCHEMA: SchemaId = SchemaId { /// /// v4 adds `physicalDeliveryOfficeName`, the attribute Entra Connect syncs /// to the cloud's `officeLocation` (see `ogar_az::SYNCED`). +/// +/// v5 adds `extensionAttribute1`..`15` as one positional bag +/// ([`AttrKind::Bag`]), the shape Graph returns them in +/// (`onPremisesExtensionAttributes`). pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -186,6 +190,12 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::Str, since: 4, }, + AttrDef { + name: "extensionAttribute", + slot: 16, + kind: AttrKind::Bag, + since: 5, + }, ]; /// AD object kinds (family-scoped codes in `object_kind`). @@ -340,6 +350,20 @@ pub fn consistency_guid(rec: &DirRecord) -> Option<[u8; 16]> { Some(rec.guid(guid_slot("mS-DS-ConsistencyGuid"))?.to_ms_bytes()) } +/// `base` is a member of a bag attribute (`extensionAttribute7`). +fn is_bag_member(base: &str) -> bool { + SCHEMA_V1 + .iter() + .filter(|d| d.kind == AttrKind::Bag) + .any(|d| { + base.len() > d.name.len() + && base[..d.name.len()].eq_ignore_ascii_case(d.name) + && base[d.name.len()..] + .parse::() + .is_ok_and(|n| (1..=BAG_LEN).contains(&n)) + }) +} + fn identity_or_dn(name: &str) -> bool { name.eq_ignore_ascii_case("objectGUID") || name.eq_ignore_ascii_case("dn") } @@ -369,6 +393,23 @@ pub fn encode( let st = |e: &dyn std::fmt::Debug| AdError::Storage(format!("{e:?}")); for def in SCHEMA_V1 { + if def.kind == AttrKind::Bag { + // Gather the numbered members into one positional bag. + let mut members: Vec<&[u8]> = Vec::with_capacity(BAG_LEN); + for n in 1..=BAG_LEN { + match entry.values(&format!("{}{n}", def.name)).as_slice() { + [] => members.push(b""), + [v] if std::str::from_utf8(v).is_ok() => members.push(v), + [_] => return Err(AdError::NotUtf8(def.name)), + _ => return Err(AdError::MultipleValues(def.name)), + } + } + if members.iter().any(|m| !m.is_empty()) { + let r = pool.push_multi(&members).map_err(|e| st(&e))?; + rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?; + } + continue; + } let mut vals = entry.values(def.name); // The DN line is authoritative for distinguishedName when the // attribute itself was not requested. @@ -389,6 +430,8 @@ pub fn encode( let r = pool.push(v).map_err(|e| st(&e))?; rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?; } + // Gathered before the match. + AttrKind::Bag => {} AttrKind::MultiStr => { if vals.iter().any(|v| std::str::from_utf8(v).is_err()) { return Err(AdError::NotUtf8(def.name)); @@ -446,7 +489,9 @@ pub fn encode( .map(|(n, _)| n.clone()) .filter(|n| { let base = n.split(';').next().unwrap_or(n); - !identity_or_dn(base) && !SCHEMA_V1.iter().any(|d| d.name.eq_ignore_ascii_case(base)) + !identity_or_dn(base) + && !SCHEMA_V1.iter().any(|d| d.name.eq_ignore_ascii_case(base)) + && !is_bag_member(base) }) .collect(); ignored.dedup(); diff --git a/crates/ogar-ad/tests/main.rs b/crates/ogar-ad/tests/main.rs index 17a9fa8e..ecac4d2d 100644 --- a/crates/ogar-ad/tests/main.rs +++ b/crates/ogar-ad/tests/main.rs @@ -350,3 +350,30 @@ fn the_office_is_kept_raw() { b"Berlin 4.12" ); } + +// v5: extensionAttribute1..15 are one bag; numbered members outside 1..15 +// stay unknown attributes; a member with two values is refused. +#[test] +fn extension_attributes_are_one_bag() { + let base = "dn: CN=A,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\n"; + let enc = |extra: &str| { + let e = ldif::parse(&format!("{base}{extra}")).unwrap().remove(0); + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + encode(&e, domain(), &mut dict, &mut pool, 1).map(|x| (x, pool)) + }; + let (e, pool) = enc("extensionAttribute15: last\nextensionAttribute3: three\nextensionAttribute16: no\nextensionAttribute0: no\n").unwrap(); + let r = e.record.str_ref(slot("extensionAttribute")).unwrap(); + assert_eq!(pool.bag_member(r, 15), Some(&b"last"[..])); + assert_eq!(pool.bag_member(r, 3), Some(&b"three"[..])); + assert_eq!(pool.bag_member(r, 1), None); + assert_eq!( + e.ignored, + vec!["extensionAttribute16", "extensionAttribute0"] + ); + assert_eq!( + enc("extensionAttribute2: a\nextensionAttribute2: b\n").unwrap_err(), + ogar_ad::AdError::MultipleValues("extensionAttribute") + ); + let (none, _) = enc("").unwrap(); + assert_eq!(none.record.str_ref(slot("extensionAttribute")), None); +} diff --git a/crates/ogar-az/src/lib.rs b/crates/ogar-az/src/lib.rs index efdc3b16..8e1b5903 100644 --- a/crates/ogar-az/src/lib.rs +++ b/crates/ogar-az/src/lib.rs @@ -20,13 +20,14 @@ use ogar_dir_core::dn::Dn; use ogar_dir_core::edge::{DirEdge, EdgeEvidence, EdgeKind}; use ogar_dir_core::record::{FLAG_DN_UNENCODED, FLAG_NON_OU_CONTAINER}; use ogar_dir_core::{ - AttrDef, AttrKind, DirRecord, Guid128, OuDictionary, SchemaFamily, SchemaId, ValuePool, base64, + AttrDef, AttrKind, BAG_LEN, DirRecord, Guid128, OuDictionary, SchemaFamily, SchemaId, + ValuePool, base64, }; use serde_json::Value; use std::collections::HashSet; /// 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::MsGraph, @@ -163,6 +164,12 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::Bool, since: 1, }, + AttrDef { + name: "onPremisesExtensionAttributes", + slot: 18, + kind: AttrKind::Bag, + since: 2, + }, ]; /// How an AD value becomes its cloud counterpart under Entra Connect sync. @@ -209,6 +216,11 @@ pub const SYNCED: &[SyncedAttribute] = &[ synced("mail", "mail", SyncTransform::Same), synced("mailNickname", "mailNickname", SyncTransform::Same), synced("proxyAddresses", "proxyAddresses", SyncTransform::Same), + synced( + "extensionAttribute", + "onPremisesExtensionAttributes", + SyncTransform::Same, + ), synced( "physicalDeliveryOfficeName", "officeLocation", @@ -349,6 +361,21 @@ pub fn encode_user( let r = pool.push(s.as_bytes()).map_err(|e| st(&e))?; rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?; } + AttrKind::Bag => { + // `{"extensionAttribute1": "x", "extensionAttribute2": null, …}` + let o = v.as_object().ok_or_else(bad)?; + let mut members: Vec<&str> = Vec::with_capacity(BAG_LEN); + for n in 1..=BAG_LEN { + match o.get(&format!("extensionAttribute{n}")) { + None | Some(Value::Null) => members.push(""), + Some(m) => members.push(m.as_str().ok_or_else(bad)?), + } + } + if members.iter().any(|m| !m.is_empty()) { + let r = pool.push_multi(&members).map_err(|e| st(&e))?; + rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?; + } + } AttrKind::MultiStr => { let arr = v.as_array().ok_or_else(bad)?; let vals: Vec<&str> = arr diff --git a/crates/ogar-az/tests/main.rs b/crates/ogar-az/tests/main.rs index ee6b9aa8..77a22ea1 100644 --- a/crates/ogar-az/tests/main.rs +++ b/crates/ogar-az/tests/main.rs @@ -19,13 +19,23 @@ fn schema_table_is_well_formed() { #[test] fn select_is_derived_from_the_schema() { - let q = select_query(1); - let names: Vec<&str> = q.split(',').collect(); - assert_eq!(names[0], "id"); - assert_eq!(names.len(), 1 + SCHEMA_V1.len()); - for d in SCHEMA_V1 { - assert!(names.contains(&d.name), "{} missing from $select", d.name); + // Each version selects exactly the attributes that existed by then, and + // the current version selects all of them. + for v in 1..=ogar_az::SCHEMA_VERSION { + let q = select_query(v); + let names: Vec<&str> = q.split(',').collect(); + assert_eq!(names[0], "id"); + let want: Vec<&str> = SCHEMA_V1 + .iter() + .filter(|d| d.since <= v) + .map(|d| d.name) + .collect(); + assert_eq!(names[1..], want[..], "v{v}"); } + assert_eq!( + select_query(ogar_az::SCHEMA_VERSION).split(',').count(), + 1 + SCHEMA_V1.len() + ); // a version before any attribute existed selects only the identity assert_eq!(select_query(0), "id"); assert!(users_url(1, 5000).ends_with("&$top=999")); @@ -186,3 +196,62 @@ fn every_synced_attribute_exists_on_both_sides_once() { "the routing address is not synced" ); } + +// onPremisesExtensionAttributes is a bag: one positional slot, entry n is +// extensionAttribute(n+1), null is absent. The AD side gathers its 15 +// numbered attributes into the identical bytes, so the pair syncs Same. +#[test] +fn the_extension_attribute_bag_matches_ad() { + let page = r#"{"value":[{"id":"7a3c9e11-2b44-4c6d-9e8f-0123456789ab", + "onPremisesExtensionAttributes":{"extensionAttribute1":"CC-4711", + "extensionAttribute2":null,"extensionAttribute7":"Berlin"}}]}"#; + let (mut d, mut p) = (OuDictionary::new(), ValuePool::new()); + let rec = ingest_page(page, tenant(), &mut d, &mut p, 0) + .unwrap() + .records[0]; + let slot = SCHEMA_V1 + .iter() + .find(|a| a.name == "onPremisesExtensionAttributes") + .unwrap() + .slot as usize; + let r = rec.str_ref(slot).unwrap(); + assert_eq!(p.bag_member(r, 1), Some(&b"CC-4711"[..])); + assert_eq!(p.bag_member(r, 7), Some(&b"Berlin"[..])); + assert_eq!(p.bag_member(r, 2), None, "null is absent"); + assert_eq!(p.bag_member(r, 0), None); + assert_eq!(p.bag_member(r, 16), None); + assert_eq!(p.get_multi(r).unwrap().len(), ogar_dir_core::BAG_LEN); + + let ldif = "dn: CN=E,OU=Staff,DC=example,DC=de\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nextensionAttribute1: CC-4711\nextensionAttribute7: Berlin\n"; + let (mut ad_d, mut ad_p) = (OuDictionary::new(), ValuePool::new()); + let ad = ogar_ad::encode( + &ogar_ad::ldif::parse(ldif).unwrap()[0], + tenant(), + &mut ad_d, + &mut ad_p, + 0, + ) + .unwrap(); + assert!(ad.ignored.is_empty(), "{:?}", ad.ignored); + let ad_slot = ogar_ad::SCHEMA_V1 + .iter() + .find(|a| a.name == "extensionAttribute") + .unwrap() + .slot as usize; + assert_eq!( + ad_p.get(ad.record.str_ref(ad_slot).unwrap()), + p.get(r), + "the two bags are byte-identical" + ); + + // All null: no slot at all, as for any absent attribute. + let empty = r#"{"value":[{"id":"7a3c9e11-2b44-4c6d-9e8f-0123456789ab", + "onPremisesExtensionAttributes":{"extensionAttribute1":null}}]}"#; + let rec = ingest_page(empty, tenant(), &mut d, &mut p, 0) + .unwrap() + .records[0]; + assert_eq!(rec.str_ref(slot), None); + // v2 requests the bag; v1 did not. + assert!(select_query(2).contains("onPremisesExtensionAttributes")); + assert!(!select_query(1).contains("onPremisesExtensionAttributes")); +} diff --git a/crates/ogar-dir-core/src/lib.rs b/crates/ogar-dir-core/src/lib.rs index b6a9d833..9310c31a 100644 --- a/crates/ogar-dir-core/src/lib.rs +++ b/crates/ogar-dir-core/src/lib.rs @@ -45,4 +45,4 @@ pub use hhtl::{HhtlError, OU_LEVELS, OuDictionary, OuHhtl}; pub use label::{CloudLabel, LabelPattern}; pub use pool::{PoolError, StrRef, ValuePool}; pub use record::{ABI_MAJOR, ABI_MINOR, DirRecord, GUID_SLOTS, RECORD_BYTES, RecordError}; -pub use schema::{AttrDef, AttrKind, SchemaFamily, SchemaId, SlotSpace}; +pub use schema::{AttrDef, AttrKind, BAG_LEN, SchemaFamily, SchemaId, SlotSpace}; diff --git a/crates/ogar-dir-core/src/pool.rs b/crates/ogar-dir-core/src/pool.rs index 26a8e00c..92a5b304 100644 --- a/crates/ogar-dir-core/src/pool.rs +++ b/crates/ogar-dir-core/src/pool.rs @@ -89,6 +89,13 @@ impl ValuePool { } /// Decode a multi-valued ref, `None` if malformed or out of range. + /// Member `n` (1-based) of a bag slot ([`crate::AttrKind::Bag`]); + /// `None` if out of range or absent (empty). + pub fn bag_member(&self, r: StrRef, n: usize) -> Option<&[u8]> { + let m = *self.get_multi(r)?.get(n.checked_sub(1)?)?; + (!m.is_empty()).then_some(m) + } + pub fn get_multi(&self, r: StrRef) -> Option> { let mut b = self.get(r)?; let mut out = Vec::new(); diff --git a/crates/ogar-dir-core/src/schema.rs b/crates/ogar-dir-core/src/schema.rs index a828d655..a5a4ec03 100644 --- a/crates/ogar-dir-core/src/schema.rs +++ b/crates/ogar-dir-core/src/schema.rs @@ -59,12 +59,21 @@ pub enum AttrKind { U32, /// Boolean (0/1), numeric slot. Absence (presence bit clear) = unknown/null. Bool, + /// A positional bag of [`BAG_LEN`] string members, one pooled + /// multi-value slot: entry `n` is member `n + 1`, an empty entry is an + /// absent member (LDAP has no empty values; Graph reports `null`). The + /// encoder gathers the members (AD `extensionAttribute1`..`15`, Graph + /// `onPremisesExtensionAttributes`), so no member name is stored. + Bag, /// A 128-bit id, inline guid slot (textual byte order). The encoder /// converts the source spelling (raw mixed-endian bytes, a labeled /// `User_`) on the way in; nothing about it is stored as text. Guid, } +/// Members of an [`AttrKind::Bag`]: the 15 extension attributes. +pub const BAG_LEN: usize = 15; + /// Which slot space an attribute occupies. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SlotSpace { @@ -84,7 +93,7 @@ impl AttrKind { /// The slot space this kind occupies. pub fn space(self) -> SlotSpace { match self { - Self::Str | Self::Bytes | Self::MultiStr => SlotSpace::Pooled, + Self::Str | Self::Bytes | Self::MultiStr | Self::Bag => SlotSpace::Pooled, Self::U32 | Self::Bool => SlotSpace::Numeric, Self::Guid => SlotSpace::Guid, } From cd55227a8e84a9c1de9a503aeb5d7b6f9593ed89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:56:27 +0000 Subject: [PATCH 4/4] Document the extension-attribute bag (V9) Also drop the stale note that the executor does not compute the address violations; lance-graph validate::address_rules does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- docs/DIRECTORY-SIMULATION-POC.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 23c56450..0c379bf6 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -274,7 +274,19 @@ no-population-intermediate rule. attributes to their cloud names (`physicalDeliveryOfficeName` → `officeLocation`, `userAccountControl` → `accountEnabled` via `effective_active`, `mS-DS-ConsistencyGuid` → `onPremisesImmutableId`); - `ogar-ad` schema v4 adds `physicalDeliveryOfficeName`. **Open:** the - executor (lance-graph) does not yet compute these three violations. + `ogar-ad` schema v4 adds `physicalDeliveryOfficeName`. The executor + (lance-graph `validate::address_rules`) computes the three violations. +- **V9 — `onPremisesExtensionAttributes` is a bag.** Graph returns it as + one object of fifteen `extensionAttributeN` keys; AD stores fifteen + separate attributes. Both land in one pooled slot of `AttrKind::Bag`: + member *n* is `extensionAttribute(n+1)`, an empty member is absent, and a + bag with no values sets no slot. Member names are not stored, so the AD + and Graph bags are byte-identical and the pair syncs `Same`. Read a + member with `ValuePool::bag_member(r, n)` (1-based, `BAG_LEN` = 15). + `ogar-ad` v5 gathers the bag (and stops reporting its members as + ignored); `ogar-az` v2 requests it. `onPremisesDistinguishedName` and + `onPremisesSamAccountName` were already ingested: the DN's OU path + (leaf CN and DC dropped, root first) is interned into the shared OU + dictionary, so a synced user's `ou_hhtl` equals its AD object's. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first.