Skip to content
Merged
123 changes: 119 additions & 4 deletions crates/ogar-ad/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,45 @@
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,
};

/// Encoder schema version understood by this crate.
pub const SCHEMA_VERSION: u16 = 1;
pub const SCHEMA_VERSION: u16 = 3;
/// 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`,
/// `mailNickname`, `proxyAddresses`, `targetAddress`), (b) say what it is
/// (`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.
///
/// 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_<cloud object id>`). 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",
Expand Down Expand Up @@ -131,6 +147,36 @@ 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,
},
AttrDef {
name: "mS-DS-ConsistencyGuid",
slot: 0,
kind: AttrKind::Guid,
since: 3,
},
AttrDef {
name: "msDS-ExternalDirectoryObjectId",
slot: 1,
kind: AttrKind::Guid,
since: 3,
},
];

/// AD object kinds (family-scoped codes in `object_kind`).
Expand All @@ -152,6 +198,20 @@ pub enum AdKind {
}

impl AdKind {
/// From the record's `object_kind` code.
pub fn from_code(code: u16) -> Option<Self> {
[
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<S: AsRef<str>>(classes: &[S]) -> Self {
let has = |c: &str| classes.iter().any(|x| x.as_ref().eq_ignore_ascii_case(c));
Expand Down Expand Up @@ -202,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; `<Label>_<guid>` whose label matches the object kind).
BadId(&'static str),
/// Pool or slot failure.
Storage(String),
}
Expand All @@ -223,6 +286,51 @@ pub struct Encoded {
pub ignored: Vec<String>,
}

/// The label an object of this kind carries in
/// `msDS-ExternalDirectoryObjectId`, if any.
pub fn cloud_label(kind: AdKind) -> Option<CloudLabel> {
match kind {
AdKind::User => Some(CloudLabel::User),
AdKind::Group => Some(CloudLabel::Group),
_ => None,
}
}

/// 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()?,
"msDS-ExternalDirectoryObjectId" => {
// The kind's own template: a User_ id on a group does not match.
let label = cloud_label(AdKind::from_code(kind)?)?;
label.pattern().strip(std::str::from_utf8(raw).ok()?)?
}
_ => return None,
};
(!g.is_nil()).then_some(g)
}

fn guid_slot(name: &str) -> usize {
SCHEMA_V1
.iter()
.find(|d| d.name == name && d.kind == AttrKind::Guid)
.expect("a guid attribute of SCHEMA_V1")
.slot as usize
}

/// Outbound: `msDS-ExternalDirectoryObjectId` as AD spells it — the stored
/// id with the label of the record's kind added back.
pub fn external_directory_object_id(rec: &DirRecord) -> Option<String> {
let g = rec.guid(guid_slot("msDS-ExternalDirectoryObjectId"))?;
let label = cloud_label(AdKind::from_code(rec.object_kind())?)?;
Some(label::render(label, g))
}

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

fn identity_or_dn(name: &str) -> bool {
name.eq_ignore_ascii_case("objectGUID") || name.eq_ignore_ascii_case("dn")
}
Expand Down Expand Up @@ -279,6 +387,13 @@ pub fn encode(
let r = pool.push_multi(&vals).map_err(|e| st(&e))?;
rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?;
}
AttrKind::Guid => {
let [v] = vals.as_slice() else {
return Err(AdError::MultipleValues(def.name));
};
let g = decode_id(def.name, v, kind).ok_or(AdError::BadId(def.name))?;
rec.set_guid(def.slot as usize, g).map_err(|e| st(&e))?;
}
AttrKind::U32 | AttrKind::Bool => {
let [v] = vals.as_slice() else {
return Err(AdError::MultipleValues(def.name));
Expand Down
109 changes: 109 additions & 0 deletions crates/ogar-ad/tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,112 @@ 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));
}

const CLOUD_ID: &str = "0b5c3a1e-7d2f-4c88-9e10-3f6a2b4c5d6e";

fn hybrid_pool(
class: &str,
external: &str,
) -> (Result<ogar_ad::Encoded, ogar_ad::AdError>, ValuePool) {
let text = format!(
"dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: {class}\nmS-DS-ConsistencyGuid:: 4AQlP4lP0xGaDAMF6CwzAQ==\nmsDS-ExternalDirectoryObjectId: {external}\n"
);
let e = &ldif::parse(&text).unwrap()[0];
let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new());
(encode(e, domain(), &mut dict, &mut pool, 1), pool)
}
fn hybrid(class: &str, external: &str) -> Result<ogar_ad::Encoded, ogar_ad::AdError> {
hybrid_pool(class, external).0
}

// In: the label is stripped and both anchors are 128-bit ids in guid slots —
// no string slot holds them. Out: AD's spelling comes back exactly.
#[test]
fn hybrid_identity_anchors_are_ids_not_strings() {
let (enc, pool) = hybrid_pool("user", &format!("User_{CLOUD_ID}"));
let enc = enc.unwrap();
assert!(enc.ignored.is_empty(), "{:?}", enc.ignored);
let r = enc.record;
// Entra Connect seeds the anchor from objectGUID by default.
assert_eq!(r.guid(slot("mS-DS-ConsistencyGuid")), Some(r.node_guid()));
assert_eq!(
r.guid(slot("msDS-ExternalDirectoryObjectId")),
Some(Guid128::parse(CLOUD_ID).unwrap())
);
// Neither id is pooled: no string slot holds the label, the id text or
// the anchor's bytes (only the DN and objectClass are pooled here).
let anchor = ogar_dir_core::base64::decode("4AQlP4lP0xGaDAMF6CwzAQ==").unwrap();
let pooled: Vec<&[u8]> = (0..32)
.filter_map(|s| r.str_ref(s))
.map(|sr| pool.get(sr).unwrap())
.collect();
assert!(!pooled.is_empty());
for v in pooled {
let has = |n: &[u8]| v.windows(n.len()).any(|w| w == n);
assert!(!has(b"User_") && !has(CLOUD_ID.as_bytes()) && !has(&anchor));
}
// Out.
assert_eq!(
ogar_ad::external_directory_object_id(&r),
Some(format!("User_{CLOUD_ID}"))
);
assert_eq!(
ogar_ad::consistency_guid(&r).map(|b| b.to_vec()),
ogar_dir_core::base64::decode("4AQlP4lP0xGaDAMF6CwzAQ==")
);
// A group carries Group_, and gets it back.
let g = hybrid("group", &format!("Group_{CLOUD_ID}"))
.unwrap()
.record;
assert_eq!(
ogar_ad::external_directory_object_id(&g),
Some(format!("Group_{CLOUD_ID}"))
);
}

// The label must match the object kind, and the id must be one: otherwise
// the entry is refused, never half-written and never stored as text.
#[test]
fn a_wrong_or_missing_label_is_refused() {
use ogar_ad::AdError::BadId;
let bad = BadId("msDS-ExternalDirectoryObjectId");
for (class, value) in [
("user", format!("Group_{CLOUD_ID}")),
("group", format!("User_{CLOUD_ID}")),
("user", CLOUD_ID.to_string()),
("user", format!("user_{CLOUD_ID}")),
("contact", format!("User_{CLOUD_ID}")),
(
"user",
"User_00000000-0000-0000-0000-000000000000".to_string(),
),
] {
assert_eq!(hybrid(class, &value).unwrap_err(), bad, "{class} {value}");
}
}
8 changes: 8 additions & 0 deletions crates/ogar-az/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ pub fn encode_user(
rec.set_num(def.slot as usize, b as u32)
.map_err(|e| st(&e))?;
}
AttrKind::Guid => {
// Graph spells a 128-bit id as the bare GUID.
let g = v
.as_str()
.and_then(|s| Guid128::parse(s).ok())
.ok_or_else(bad)?;
rec.set_guid(def.slot as usize, g).map_err(|e| st(&e))?;
}
AttrKind::U32 => {
let n = v
.as_u64()
Expand Down
47 changes: 45 additions & 2 deletions crates/ogar-dir-core/src/base64.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -30,8 +31,50 @@ pub fn decode(s: &str) -> Option<Vec<u8>> {
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<u8> = (0..=255).collect();
assert_eq!(super::decode(&super::encode(&all)).unwrap(), all);
}

#[test]
fn decodes_rfc4648_vectors() {
for (enc, dec) in [
Expand Down
Loading
Loading