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
60 changes: 57 additions & 3 deletions crates/ogar-ad/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 3;
pub const SCHEMA_VERSION: u16 = 5;
/// This crate's schema id.
pub const SCHEMA: SchemaId = SchemaId {
family: SchemaFamily::AdDs,
Expand Down Expand Up @@ -56,6 +56,13 @@ pub const SCHEMA: SchemaId = SchemaId {
/// `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.
///
/// 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",
Expand Down Expand Up @@ -177,6 +184,18 @@ pub const SCHEMA_V1: &[AttrDef] = &[
kind: AttrKind::Guid,
since: 3,
},
AttrDef {
name: "physicalDeliveryOfficeName",
slot: 15,
kind: AttrKind::Str,
since: 4,
},
AttrDef {
name: "extensionAttribute",
slot: 16,
kind: AttrKind::Bag,
since: 5,
},
];

/// AD object kinds (family-scoped codes in `object_kind`).
Expand Down Expand Up @@ -331,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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
&& base[d.name.len()..]
.parse::<usize>()
.is_ok_and(|n| (1..=BAG_LEN).contains(&n))
Comment thread
AdaWorldAPI marked this conversation as resolved.
})
}

fn identity_or_dn(name: &str) -> bool {
name.eq_ignore_ascii_case("objectGUID") || name.eq_ignore_ascii_case("dn")
}
Expand Down Expand Up @@ -360,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.
Expand All @@ -380,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));
Expand Down Expand Up @@ -437,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();
Expand Down
43 changes: 43 additions & 0 deletions crates/ogar-ad/tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,46 @@ 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"
);
}

// 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);
}
3 changes: 3 additions & 0 deletions crates/ogar-az/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
124 changes: 122 additions & 2 deletions crates/ogar-az/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -163,8 +164,112 @@ 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.
#[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(
"extensionAttribute",
"onPremisesExtensionAttributes",
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,
),
Comment thread
AdaWorldAPI marked this conversation as resolved.
];

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)]
Expand Down Expand Up @@ -256,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
Expand Down
Loading
Loading