diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index 5cde5e8c..366cb06c 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -3,7 +3,39 @@ //! replace (`from`): applying it where `from` no longer holds is refused, and //! the same expectation becomes a plan operation's [`Precondition`](crate::Precondition). -use ogar_dir_core::Guid128; +use ogar_dir_core::{Guid128, OuHhtl}; + +/// Kind of a directory node. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeKind { + /// User. + User, + /// Group. + Group, +} + +/// The canonical semantic fields of one node — exactly what a snapshot row +/// holds, nothing else. A created node carries it as its content; a deleted +/// node carries it as the compare-and-set expectation (delete what was +/// read, never whatever is there now). +/// +/// `active` is the single enabled flag as observed by the one source the +/// node came from (AD: `userAccountControl` ACCOUNTDISABLE clear). How AD +/// and Entra enabled state combine is not settled; see +/// `docs/DIRECTORY-SIMULATION-POC.md` V4. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NodeState { + /// Kind. + pub kind: NodeKind, + /// Enabled. Users only: groups have no enabled flag and carry `true`. + pub active: bool, + /// Raw UPN. + pub upn: Option, + /// Raw primary SMTP. + pub primary_smtp: Option, + /// OU location (numeric HHTL, never a DN), if known. + pub ou: Option, +} /// Attribute a change can set. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -16,15 +48,23 @@ pub enum Attribute { /// One semantic change. `Ord` is total, so a sorted change list is a /// canonical form (used for determinism). +/// +/// Variant order is load-bearing: a sorted list is also a safe application +/// order, respecting every dependency between kinds of change: +/// +/// 1. `RemoveMembership` — a node's edges go before the node; +/// 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. `CreateNode` — after every address it needs is free; +/// 5. `AddMembership` — once both endpoints exist. +/// +/// Within step 3, the plan additionally orders a chain of renames (one +/// object releases the value the next claims). A cycle of renames (two +/// nodes swapping an address) needs a temporary value, which no change list +/// carries yet. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Change { - /// `user` becomes a member of `group`. - AddMembership { - /// Member. - user: Guid128, - /// Group. - group: Guid128, - }, /// `user` stops being a member of `group`. RemoveMembership { /// Member. @@ -32,6 +72,14 @@ pub enum Change { /// Group. group: Guid128, }, + /// A node stops existing. Refused while it still has a membership: the + /// change list must remove those edges itself, so none is stranded. + DeleteNode { + /// Identity. + node: Guid128, + /// The state the deletion expects to remove (compare-and-set). + state: NodeState, + }, /// Compare-and-set of one attribute. SetAttribute { /// Object. @@ -43,6 +91,20 @@ pub enum Change { /// New value (raw). to: Option, }, + /// A node that did not exist comes into existence. + CreateNode { + /// Identity. + node: Guid128, + /// Its content. + state: NodeState, + }, + /// `user` becomes a member of `group`. + AddMembership { + /// Member. + user: Guid128, + /// Group. + group: Guid128, + }, } /// Comparison form of a UPN / SMTP address: trimmed and lowercased with diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index 1fbc8afb..d4f7360f 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -20,7 +20,7 @@ pub mod plan; pub mod provenance; pub mod violation; -pub use change::{Attribute, Change, normalize}; +pub use change::{Attribute, Change, NodeKind, NodeState, normalize}; 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 4f89bc32..643c6f77 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -6,20 +6,19 @@ //! actuator can re-read that one fact from reality before acting (still //! true → execute; changed → re-observe and re-plan). -use crate::change::{Attribute, Change}; +use crate::change::{Attribute, Change, NodeState, normalize}; use crate::provenance::VersionId; use ogar_dir_core::Guid128; +use std::collections::{BTreeMap, BTreeSet}; /// A technology-neutral directory operation. +/// +/// Variant order is load-bearing and mirrors [`Change`]: removals, deletes, +/// attribute sets, 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)] pub enum Operation { - /// Add `member` to `group`. - AddGroupMember { - /// Group. - group: Guid128, - /// Member. - member: Guid128, - }, /// Remove `member` from `group`. RemoveGroupMember { /// Group. @@ -27,6 +26,11 @@ pub enum Operation { /// Member. member: Guid128, }, + /// Delete `object`. + DeleteObject { + /// Object. + object: Guid128, + }, /// Set an attribute (`None` clears it). SetAttribute { /// Object. @@ -36,6 +40,20 @@ pub enum Operation { /// New value. value: Option, }, + /// Create `object` with `state`. + CreateObject { + /// Object. + object: Guid128, + /// Its content. + state: NodeState, + }, + /// Add `member` to `group`. + AddGroupMember { + /// Group. + group: Guid128, + /// Member. + member: Guid128, + }, } /// What reality must still look like for the operation to be safe. @@ -47,6 +65,12 @@ pub enum Precondition { IsMember, /// The attribute must still hold this value. AttributeEquals(Option), + /// No object with this identity may exist yet. + ObjectAbsent, + /// The object must still exist in exactly this state and have no + /// membership on either side (none was added since the observation, and + /// the plan's own removals have run), so a delete never strands an edge. + ObjectRemovable(NodeState), } /// One planned step. @@ -61,6 +85,17 @@ pub struct PlannedOp { impl From for PlannedOp { fn from(c: Change) -> Self { match c { + Change::CreateNode { node, state } => Self { + op: Operation::CreateObject { + object: node, + state, + }, + precondition: Precondition::ObjectAbsent, + }, + Change::DeleteNode { node, state } => Self { + op: Operation::DeleteObject { object: node }, + precondition: Precondition::ObjectRemovable(state), + }, Change::AddMembership { user, group } => Self { op: Operation::AddGroupMember { group, @@ -109,10 +144,90 @@ impl ExecutionPlan { let mut ops: Vec = diff.into_iter().map(PlannedOp::from).collect(); ops.sort(); ops.dedup(); + order_value_transfers(&mut ops); Self { basis, target, ops } } } +/// The value an attribute op claims, and the value it releases (by +/// comparison form: uniqueness is case-insensitive). +fn transfer(p: &PlannedOp) -> Option<((Attribute, String), (Attribute, String))> { + match (&p.op, &p.precondition) { + ( + Operation::SetAttribute { + attribute, value, .. + }, + Precondition::AttributeEquals(from), + ) => Some(( + ( + *attribute, + value.as_deref().map(normalize).unwrap_or_default(), + ), + ( + *attribute, + from.as_deref().map(normalize).unwrap_or_default(), + ), + )), + _ => None, + } +} + +/// Within the (contiguous) attribute-set block of a sorted plan, run every +/// set that releases a value before any set on another object that claims +/// it: object 2 `b → c` before object 1 `a → b`. Kahn's algorithm, ties +/// broken by the canonical order, so the result is deterministic. Sets in a +/// rename cycle (two objects swapping a value) cannot be ordered without a +/// temporary value; they keep their canonical order at the end of the block. +fn order_value_transfers(ops: &mut [PlannedOp]) { + let Some(start) = ops.iter().position(|p| transfer(p).is_some()) else { + return; + }; + let len = ops[start..] + .iter() + .take_while(|p| transfer(p).is_some()) + .count(); + let block = &mut ops[start..start + len]; + let keys: Vec<_> = block.iter().filter_map(transfer).collect(); + let object = |p: &PlannedOp| match &p.op { + Operation::SetAttribute { object, .. } => Some(*object), + _ => None, + }; + // releaser[v] = the ops releasing value v; an empty value releases nothing. + let mut releasers: BTreeMap<&(Attribute, String), Vec> = BTreeMap::new(); + for (i, (_, released)) in keys.iter().enumerate() { + if !released.1.is_empty() { + releasers.entry(released).or_default().push(i); + } + } + // Edge r → c when r releases what c claims, on a different object. + let mut blockers = vec![0usize; len]; + let mut unblocks: Vec> = vec![Vec::new(); len]; + for (c, (claimed, _)) in keys.iter().enumerate() { + for &r in releasers.get(claimed).into_iter().flatten() { + if r != c && object(&block[r]) != object(&block[c]) { + blockers[c] += 1; + unblocks[r].push(c); + } + } + } + let mut ready: BTreeSet = (0..len).filter(|&i| blockers[i] == 0).collect(); + let mut order = Vec::with_capacity(len); + while let Some(i) = ready.pop_first() { + order.push(i); + for &c in &unblocks[i] { + blockers[c] -= 1; + if blockers[c] == 0 { + ready.insert(c); + } + } + } + // A cycle leaves its members blocked: keep them, in canonical order. + let placed: BTreeSet = order.iter().copied().collect(); + order.extend((0..len).filter(|i| !placed.contains(i))); + let sorted: Vec = order.into_iter().map(|i| block[i].clone()).collect(); + block.clone_from_slice(&sorted); +} + /// Why no plan was derived. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PlanError { @@ -120,20 +235,25 @@ pub enum PlanError { NotDesired(VersionId), /// The target is unknown. UnknownVersion(VersionId), - /// The latest observation (`basis`) no longer has the node set the - /// desired version (`target`) was built on: users or groups were - /// created or deleted since. Simulate again from the new observation. - NodeSetChanged { + /// The latest observation (`basis`) already holds `node`, which the + /// desired version (`target`) creates, but with a kind, enabled flag or + /// OU that no change can converge yet (the change algebra sets only UPN + /// and primary SMTP). Rather than claim the create is done, no plan is + /// derived; simulate again from the new observation. + Unconvergeable { /// The latest observation. basis: VersionId, /// The desired version. target: VersionId, + /// The node whose observed state cannot reach the desired one. + node: Guid128, }, } #[cfg(test)] mod tests { use super::*; + use crate::change::NodeKind; #[test] fn a_repeated_change_is_planned_once() { @@ -145,6 +265,125 @@ mod tests { assert_eq!(p.ops.len(), 1); } + fn state(kind: NodeKind) -> NodeState { + NodeState { + kind, + active: true, + upn: None, + primary_smtp: None, + ou: None, + } + } + + #[test] + fn a_sorted_plan_frees_before_it_claims() { + let g = |n| Guid128([n; 16]); + let with_smtp = |smtp: &str| NodeState { + primary_smtp: Some(smtp.into()), + ..state(NodeKind::User) + }; + // Given in the worst order: user 5 is deleted and user 1 created + // with the address 5 had, user 3 is renamed, user 1 joins a group. + let plan = ExecutionPlan::from_diff( + VersionId(0), + VersionId(1), + vec![ + Change::AddMembership { + user: g(1), + group: g(9), + }, + Change::CreateNode { + node: g(1), + state: with_smtp("shared@example.test"), + }, + Change::SetAttribute { + node: g(3), + attribute: Attribute::PrimarySmtp, + from: Some("c@example.test".into()), + to: Some("c2@example.test".into()), + }, + Change::DeleteNode { + node: g(5), + state: with_smtp("shared@example.test"), + }, + Change::RemoveMembership { + user: g(5), + group: g(9), + }, + ], + ); + let ops: Vec<_> = plan.ops.iter().map(|p| &p.op).collect(); + assert!(matches!(ops[0], Operation::RemoveGroupMember { member, .. } if *member == g(5))); + // The delete frees "shared@" before the create claims it. + assert_eq!(ops[1], &Operation::DeleteObject { object: g(5) }); + assert!(matches!(ops[2], Operation::SetAttribute { object, .. } if *object == g(3))); + assert!(matches!(ops[3], Operation::CreateObject { object, .. } if *object == g(1))); + assert!(matches!(ops[4], Operation::AddGroupMember { member, .. } if *member == g(1))); + assert_eq!( + plan.ops[1].precondition, + Precondition::ObjectRemovable(with_smtp("shared@example.test")) + ); + assert_eq!(plan.ops[3].precondition, Precondition::ObjectAbsent); + } + + fn rename(n: u8, from: &str, to: &str) -> Change { + Change::SetAttribute { + node: Guid128([n; 16]), + attribute: Attribute::PrimarySmtp, + from: Some(from.into()), + to: Some(to.into()), + } + } + fn renamed(plan: &ExecutionPlan) -> Vec { + plan.ops + .iter() + .filter_map(|p| match &p.op { + Operation::SetAttribute { object, .. } => Some(object.0[0]), + _ => None, + }) + .collect() + } + + #[test] + fn a_value_is_released_before_another_object_claims_it() { + // 1: a → b, 2: b → c, 3: c → d. Canonical order is 1, 2, 3; the safe + // order is 3, 2, 1 (each frees what the previous one takes). Case + // differences do not hide the dependency. + let plan = ExecutionPlan::from_diff( + VersionId(0), + VersionId(1), + vec![ + rename(1, "a@x.test", "B@x.test"), + rename(2, "b@x.test", "c@x.test"), + rename(3, "C@x.test", "d@x.test"), + ], + ); + assert_eq!(renamed(&plan), vec![3, 2, 1]); + } + + #[test] + fn independent_renames_keep_canonical_order_and_a_cycle_is_kept() { + let plan = ExecutionPlan::from_diff( + VersionId(0), + VersionId(1), + vec![ + rename(2, "p@x.test", "q@x.test"), + rename(1, "r@x.test", "s@x.test"), + ], + ); + assert_eq!(renamed(&plan), vec![1, 2]); + // A swap cannot be ordered: both stay, in canonical order. + let swap = ExecutionPlan::from_diff( + VersionId(0), + VersionId(1), + vec![ + rename(1, "a@x.test", "b@x.test"), + rename(2, "b@x.test", "a@x.test"), + ], + ); + assert_eq!(renamed(&swap), vec![1, 2]); + } + #[test] fn lowering_carries_the_basis_precondition_and_no_transport() { let g = |n| Guid128([n; 16]); @@ -165,15 +404,15 @@ mod tests { ], ); assert_eq!( - plan.ops[0].op, + plan.ops[1].op, Operation::AddGroupMember { group: g(9), member: g(1) } ); - assert_eq!(plan.ops[0].precondition, Precondition::NotMember); + assert_eq!(plan.ops[1].precondition, Precondition::NotMember); assert_eq!( - plan.ops[1].precondition, + plan.ops[0].precondition, Precondition::AttributeEquals(Some("bob@example.test".into())) ); } diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index b1d36617..302b51c9 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -136,12 +136,41 @@ no-population-intermediate rule. - **V1 — Lance persistence.** `VersionedGraph` uses `u32` ids and an additions-only diff. Persisting this store needs 128-bit keys and removal- and attribute-aware diffs upstream, or a dedicated directory dataset. -- **V2 — node creation and deletion.** These are not in the `Change` algebra - yet. A diff across different node sets returns `NodeSetChanged`. -- **V3 — OU-HHTL lane width.** The packed lane addresses prefixes up to - depth 4, which is exact. Deeper prefixes are refused (`SubtreeTooDeep`). The - candidate HHTL64 (8 × u8) would make all 8 levels one `MatchU64`. -- **V4 — "active" in Entra.** Active is derived only from AD - `userAccountControl`. Entra `accountEnabled` is not mapped yet. +- **V2 — node creation and deletion (closed).** `Change::CreateNode` / + `Change::DeleteNode` carry a `NodeState` (kind, active, UPN, primary SMTP, + `OuHhtl`): the content to create, or the compare-and-set expectation of a + delete. A delete is refused while the node still has a membership on + either side, so an edge cannot be stranded; its plan precondition + (`ObjectRemovable`) re-checks that against reality. `Change` and + `Operation` variant order is a safe application order: membership + removals, deletes, attribute sets, creates, membership adds — an address + is freed before it is claimed, including along a chain of renames + (`b → c` runs before `a → b`). A cycle of renames (two nodes swapping an + address) is not orderable without a temporary value and stays open. + The plan is the desired version's net intent rebased onto the latest + observation: work reality already shows is dropped, and drift outside the + intent is neither planned nor reverted. Not representable yet: a change of + `active`, of OU or of kind on a node that exists in both versions. +- **V3 — OU-HHTL depth 8 (blocked, needs a decision).** `OuHhtl` is + `[u16; 8]`: 128 bits, per-parent segment ids up to 65,535. One `u64` holds + 4 such levels. The "HHTL64 (8 × u8)" candidate fits 8 levels only by + narrowing a segment to 255 children per parent (zero ends the path), which changes the + meaning of every persisted id and dictionary, and it leaves no bit for a + domain. Domain context today is not per node at all: it is the scope of + the `OuDictionary` (one per AD domain / Entra tenant), so two domains in + one snapshot already have ambiguous segment ids. Options: (a) two `u64` + lanes, levels 0..3 and 4..7, matched by two `MatchU64` in one program; + exact, no ABI change, but not one compare; (b) narrow to 8 × u8 with a + refusal past 255 children; (c) a scope id lane plus (a). The snapshot now + also keeps the exact `OuHhtl` per node, so no option loses data. +- **V4 — "active" across AD and Entra (blocked, needs a decision).** Graph + `accountEnabled` is ingested by `ogar-az` (null = unknown), but an Entra + user is a different node from its AD source: `sync_edges` links them as + evidence and keeps both. There is therefore no node on which "AD active + and Entra active" is a pair. Separately, `from_ad` treats a missing + `userAccountControl` as enabled: unknown currently collapses into true. + Candidate policies: AD authoritative for synced users; Entra + authoritative; active only if every known source says enabled (unknown + ignored); active only if every source is known and enabled. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first.