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
76 changes: 70 additions & 6 deletions crates/ogar-dir-sim/src/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ pub enum NodeKind {
/// 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.
/// `active` is three-valued: `Some(true)` enabled, `Some(false)` disabled,
/// `None` unknown (no source reported a flag). An unknown flag is never read
/// as enabled. Several sources combine through [`effective_active`].
#[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,
/// Enabled; `None` = unknown. Users only: groups have no enabled flag
/// and carry `Some(true)`.
pub active: Option<bool>,
/// UPN.
pub upn: Option<ValueId>,
/// Primary SMTP.
Expand All @@ -64,6 +64,39 @@ pub struct NodeState {
pub dn: Option<Dn128>,
}

/// The one definition of "active" across sources (V4, decided 2026-10-07):
/// **active iff at least one source is known and no known source says
/// disabled. An unknown source abstains.**
///
/// ```text
/// known_any ∧ ¬known_disabled
/// = (ad_known ∨ entra_known) ∧ (¬ad_known ∨ ad_enabled) ∧ (¬entra_known ∨ entra_enabled)
/// ```
///
/// | AD | Entra | effective |
/// |---|---|---|
/// | enabled | enabled | active |
/// | enabled | unknown | active |
/// | unknown | enabled | active |
/// | disabled | enabled | inactive |
/// | enabled | disabled | inactive |
/// | disabled | unknown | inactive |
/// | unknown | disabled | inactive |
/// | unknown | unknown | unknown |
///
/// Absence of evidence never disables; negative evidence always does; two
/// unknowns stay unknown rather than collapsing to either value. Every
/// executor that derives "active" from more than one source must agree with
/// this function row for row.
#[must_use]
pub const fn effective_active(ad: Option<bool>, entra: Option<bool>) -> Option<bool> {
match (ad, entra) {
(None, None) => None,
(Some(false), _) | (_, Some(false)) => Some(false),
_ => Some(true),
}
}

/// Attribute a change can set.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Attribute {
Expand Down Expand Up @@ -150,6 +183,37 @@ pub fn normalize(s: &str) -> String {

#[cfg(test)]
mod tests {
use super::effective_active;

/// The full 3×3 table, every row. A combinator that read unknown as
/// enabled (the old `from_ad` default) fails the `(None, None)` row; one
/// that read it as disabled fails the four rows with a single known
/// `true`.
#[test]
fn effective_active_is_the_v4_table() {
let (e, d, u) = (Some(true), Some(false), None);
let rows = [
(e, e, e),
(e, u, e),
(u, e, e),
(d, e, d),
(e, d, d),
(d, u, d),
(u, d, d),
(d, d, d),
(u, u, u),
];
for (ad, entra, want) in rows {
assert_eq!(
effective_active(ad, entra),
want,
"ad={ad:?} entra={entra:?}"
);
// Source order does not matter.
assert_eq!(effective_active(entra, ad), want, "swapped");
}
}

use super::normalize;

#[test]
Expand Down
4 changes: 3 additions & 1 deletion crates/ogar-dir-sim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ pub mod plan;
pub mod provenance;
pub mod violation;

pub use change::{Attribute, Change, KeyId, NodeKind, NodeState, ValueId, normalize};
pub use change::{
Attribute, Change, KeyId, NodeKind, NodeState, ValueId, effective_active, 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};
2 changes: 1 addition & 1 deletion crates/ogar-dir-sim/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ mod tests {
fn state(kind: NodeKind) -> NodeState {
NodeState {
kind,
active: true,
active: Some(true),
upn: None,
primary_smtp: None,
dn: None,
Expand Down
22 changes: 13 additions & 9 deletions docs/DIRECTORY-SIMULATION-POC.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,18 @@ no-population-intermediate rule.
(`[u16; 8]`) stays the ingress and record-wire format; `Dn128::from_ou_hhtl`
converts it and **fails closed** at a 257th child (`ChildCodeOverflow`) —
`from_ad` then refuses the whole observation, never hashing or truncating.
- **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. **Recorded, not fixed:** `from_ad` treats a
missing `userAccountControl` as enabled — unknown 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.
- **V4 — "active" across AD and Entra (closed by ruling, 2026-10-07).**
`NodeState::active` is three-valued (`Some(true)` / `Some(false)` /
`None` = unknown). Across sources, **active iff at least one source is
known and no known source says disabled; an unknown source abstains**:
`(ad_known ∨ entra_known) ∧ (¬ad_known ∨ ad_enabled) ∧ (¬entra_known ∨
entra_enabled)`. Absence of evidence never disables, negative evidence
always does, and unknown/unknown stays unknown. The one definition is
`ogar_dir_sim::effective_active`, pinned row for row; every executor must
agree with it. `from_ad` no longer reads a missing `userAccountControl` as
enabled: it is unknown. **Not yet wired:** an Entra user is still a
separate node from its AD source (`sync_edges` links them as evidence), and
no observation path merges the two, so today each node carries its one
source's flag.
- **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so
it needs this OGAR PR merged first.
Loading