diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index dc309a4a8..64453667f 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,10 @@ +## 2026-10-11 (2) — dir-sim-persist-v1 — a dir-sim history that survives a restart (W-3) → `.claude/plans/dir-sim-persist-v1.md` + +**Status:** ACTIVE (D-DSP-0..3). Consumer ask: HubSPO-rs handover W-3. + +- The persisted form is the store's inputs (root observations, dictionary in id order, versions verbatim, tags, verdicts), never the derived snapshot lanes. +- `lance-graph-dir-sim` stays free of I/O; a separate crate writes the image to Lance. + ## 2026-10-11 — concept-labels-hotplug-v1 — labels of a concept, pulled through the plug → `.claude/plans/concept-labels-hotplug-v1.md` **Status:** ACTIVE (D-LBL-0..5). Operator DECISION 2026-10-11: labels are ontology data in `ogar-vocab`, pulled through `hotplug.rs` only for plugged concepts; every language now, a non-breaking per-consumer spec later; customer binaries activate at build time; `0x0103` German preferred "Zeiterfassung". diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 2abaa464f..62f3c122a 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -1,3 +1,14 @@ +## D-DSP — dir-sim history that survives a restart (2026-10-11) + +Plan: `.claude/plans/dir-sim-persist-v1.md`. + +| D-id | Deliverable | Status | Gate | +|---|---|---|---| +| **D-DSP-0** | `StoreImage` + `VersionStore::image` / `restore` (dir-sim, no I/O) | In PR | P-W3-0: round trip answers every read the same; damaged image refused | +| **D-DSP-1** | `VersionStore::revert(current, earlier, evidence)` — undo as a version | In PR | the revert's view equals `earlier`'s | +| **D-DSP-2** | Lance writer/reader crate for `StoreImage` | Queued | write → read → restore equals the in-memory image | +| **D-DSP-3** | `ViewActors` (W-2) + `from_graph` groups (wishlist #19) in dir-sim | In PR | disable runs red | + ## D-LBL — labels of a concept, pulled through the plug (2026-10-11) Plan: `.claude/plans/concept-labels-hotplug-v1.md`. diff --git a/.claude/plans/dir-sim-persist-v1.md b/.claude/plans/dir-sim-persist-v1.md new file mode 100644 index 000000000..13e8e52ca --- /dev/null +++ b/.claude/plans/dir-sim-persist-v1.md @@ -0,0 +1,60 @@ +# dir-sim persistence (W-3) — v1 + +**Status:** ACTIVE (2026-10-11). Consumer ask: HubSPO-rs handover +`.claude/handovers/2026-10-10-1600-hubspo-rs-to-lance-graph.md` W-3; meaning +half OGAR `.claude/handovers/2026-10-10-1600-hubspo-rs-to-ogar-dir-sim.md` +W-3 (`VersionId` maps 1:1 onto a Lance dataset version, `provenance.rs:16`). + +## Overview + +`VersionStore` is in memory only. HubSPO-rs needs a history that survives a +restart without re-observing AD, keeps provenance and tags, and can restore +an earlier version to undo a directory change. + +## What is persisted, and why not the lanes + +The persisted form is the store's **inputs**, not its derived planes: + +| part | persisted as | why | +|---|---|---| +| each root | the `Observation` it was built from | `Snapshot` lanes are derived and lossy (`alias_key` keeps only the comparison form; `owner` / `mail_owner` are computed); their layout changes as planes are added. The observation is the source's own schema. | +| the dictionary | raw values in `ValueId` order | comparison keys and routing aliases are recomputed by `Dicts::intern`; interning the values in order reproduces every id, so persisted `Change`s stay valid. | +| each version | `Version` (id, parent, origin, delta) verbatim | the delta is what a simulation produced; rules are not re-run. | +| tags, verdicts | as held | a verdict is evidence about a version, kept with it. | + +Restore = intern the dictionary in order, rebuild each root with +`Snapshot::build`, push the versions in id order. A restored store answers +every `view` exactly as the original. + +Restoring an earlier version to undo a change is a new version, never a +rewrite: `diff(current, earlier)` is the change list that reverts, recorded +as a simulated version whose origin names the restored version. + +## Work items + +- [x] **P-W3-0 (probe first):** `StoreImage` (owned, in `lance-graph-dir-sim`, + no I/O) + `VersionStore::image()` / `VersionStore::restore(image)`; + test: a store with two roots, simulated versions, tags and verdicts + round-trips, and every version's view, diff and plan are equal before + and after. Done (`tests/persist.rs`); five disable + runs red: dictionary order, verdicts, tags, delta check, dictionary + completeness. +- [x] `VersionStore` keeps each root's `Observation` (needed for the image). +- [x] `VersionStore::revert(current, earlier, evidence)` — the undo as a version, recorded under `REVERT`. +- [ ] Lance writer/reader in a separate crate (`lance-graph-dir-sim-lance`), + so `lance-graph-dir-sim` keeps its no-I/O rule: one Lance dataset per + store, tables `values`, `nodes`, `members`, `proxies`, `versions`, + `changes`, `tags`; one Lance version per dir-sim version written. +- [ ] Board: STATUS_BOARD row; HubSPO handover reply. + +## Open + +- Whether a Lance version per dir-sim version, or per write batch, is the + right mapping (OGAR says 1:1 "by design"; a batch import would write many + versions at once). +- Encoding of `Change` / `Origin` rows: they are OGAR types; their columnar + form belongs next to them in OGAR or here. +- `RuleId::name` is `&'static str` (OGAR `provenance.rs:29`). An image held in + memory restores fine, but one read back from bytes has no `'static` name to + put there. D-DSP-2 needs either a rule registry the reader maps names + through (unknown name = refusal), or an owned name in OGAR's `RuleId`. diff --git a/crates/lance-graph-dir-sim/Cargo.toml b/crates/lance-graph-dir-sim/Cargo.toml index 983e7499b..bbf4ff2f5 100644 --- a/crates/lance-graph-dir-sim/Cargo.toml +++ b/crates/lance-graph-dir-sim/Cargo.toml @@ -1,7 +1,14 @@ # lance-graph-dir-sim — simulate a directory's desired state over the SoA # substrate. EXCLUDED from the lance-graph workspace (own [workspace] root) -# because it path-deps the OGAR sibling — the same shape as -# lance-graph-report-ogar / lance-graph-ogar. +# because it depends on OGAR — the same shape as lance-graph-ogar. +# +# OGAR is a git dependency, so a consumer in another repository (HubSPO-rs) +# can depend on this crate by git (ISS-LG-DIRSIM-NOT-GIT-CONSUMABLE). The +# [patch] below redirects that git source onto the sibling checkout for this +# crate's own build and CI, so a paired OGAR change is tested before it +# merges. `[patch]` only applies in the workspace root: a consumer that also +# names OGAR by path (Spear, Stalwart) must repeat it in its root, or it +# builds two `ogar-dir-core` crates and the types do not unify. # # Direction: this crate depends on BOTH sides; neither depends on it. OGAR # owns the semantic vocabulary (ogar-dir-sim: Change, provenance, Violation, @@ -24,17 +31,34 @@ description = "Versioned directory graph over SoA lanes: observed snapshots shar [dependencies] lance-graph-quack = { path = "../lance-graph-quack" } lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } -ogar-dir-core = { path = "../../../OGAR/crates/ogar-dir-core" } -ogar-dir-sim = { path = "../../../OGAR/crates/ogar-dir-sim" } -ogar-ad = { path = "../../../OGAR/crates/ogar-ad" } -ogar-az = { path = "../../../OGAR/crates/ogar-az" } +ogar-dir-core = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } +ogar-dir-sim = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } +ogar-ad = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } +ogar-az = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } +# The actor source over a view (`actors`, feature `rbac`): the contract's +# zero-dep `ActorSource` socket. +lance-graph-contract = { path = "../lance-graph-contract", optional = true } # RFC 4518 string preparation (NFKC) for case-ignore LDAP matching. unicode-normalization = "0.1" # Full Unicode case folding (RFC 3454 B.2) for the same. unicase = "2" +[features] +rbac = ["dep:lance-graph-contract"] + # mask-risc names ndarray by its canonical git coordinate; point it at the # sibling checkout so this workspace has one ndarray identity and needs no # network for it (same patch as the lance-graph root). [patch."https://github.com/AdaWorldAPI/ndarray"] ndarray = { path = "../../../ndarray" } + +# This crate's own build uses the OGAR sibling checkout (see the header). +[patch."https://github.com/AdaWorldAPI/OGAR"] +ogar-dir-core = { path = "../../../OGAR/crates/ogar-dir-core" } +ogar-dir-sim = { path = "../../../OGAR/crates/ogar-dir-sim" } +ogar-ad = { path = "../../../OGAR/crates/ogar-ad" } +ogar-az = { path = "../../../OGAR/crates/ogar-az" } + +[[test]] +name = "view_actors" +required-features = ["rbac"] diff --git a/crates/lance-graph-dir-sim/src/actors.rs b/crates/lance-graph-dir-sim/src/actors.rs new file mode 100644 index 000000000..f23c99656 --- /dev/null +++ b/crates/lance-graph-dir-sim/src/actors.rs @@ -0,0 +1,143 @@ +//! The roles a principal holds, read from a [`View`]: the contract's +//! [`ActorSource`] over the directory. +//! +//! A role is granted to a group, never to an address: [`GroupRoles`] keys +//! the grants by the group's GUID. A principal holds the roles of every +//! security group whose SID it carries ([`View::security_identifiers`]), +//! directly or through nesting. Only a security group has a SID, so a +//! distribution group passes on nothing, and nesting reaches a role only +//! through a chain of security groups. +//! +//! Fails closed in three places: +//! +//! * a group whose grants were never read grants nothing. It is not the same +//! as a group read with no grants, so [`ViewActors::unread_groups`] names +//! it, and a caller that needs the full picture can refuse; +//! * a role the consumer's binding does not declare is dropped, so group +//! data can never hand out a role the plug did not ask for; +//! * any actor other than the principal holds nothing. +//! +//! Roles are unscoped here; a consumer that binds a role to rows (a mailbox, +//! a practice) supplies its own `memberships_of`. + +use std::collections::HashMap; + +use lance_graph_contract::rbac::{ActorId, RoleId}; +use lance_graph_contract::rbac_plug::{ActorSource, RbacBinding}; +use ogar_dir_core::Guid128; + +use crate::View; + +/// The roles granted to each group, keyed by the group's GUID. +/// +/// A group is either read (with its roles, possibly none) or absent, which +/// means its grants were never read. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GroupRoles { + read: HashMap>, +} + +impl GroupRoles { + /// No group read yet. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record the grants of `group` as read: `roles`, which may be empty. + /// Reading a group again replaces what was recorded. + pub fn read(&mut self, group: Guid128, roles: &[RoleId]) -> &mut Self { + self.read.insert(group, roles.to_vec()); + self + } + + /// The roles of `group`, or `None` when its grants were never read. + #[must_use] + pub fn of(&self, group: &Guid128) -> Option<&[RoleId]> { + self.read.get(group).map(Vec::as_slice) + } +} + +/// One principal's roles, read from a [`View`] once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ViewActors { + subject: String, + principal: Guid128, + roles: Vec, + unread: Vec, +} + +impl ViewActors { + /// The roles `principal` holds in `view`: the grants of every security + /// group whose SID it carries, kept only where `binding` declares the + /// role. Decisions are asked under the principal's GUID as text + /// ([`subject`](Self::subject)). + /// + /// A principal that is not a user in `view` holds nothing. + #[must_use] + pub fn for_principal( + view: &View<'_>, + principal: &Guid128, + grants: &GroupRoles, + binding: &RbacBinding, + ) -> Self { + let declared: Vec = binding.declared_grants().iter().map(|(r, _)| *r).collect(); + let mut roles: Vec = Vec::new(); + let mut unread = Vec::new(); + for group in view.security_identifiers(principal) { + match grants.of(&group) { + Some(granted) => { + for role in granted { + if declared.contains(role) && !roles.contains(role) { + roles.push(*role); + } + } + } + None => unread.push(group), + } + } + roles.sort_unstable(); + Self { + subject: principal.to_string(), + principal: *principal, + roles, + unread, + } + } + + /// The principal's GUID as text, the id decisions are asked under. + #[must_use] + pub fn subject(&self) -> &str { + &self.subject + } + + /// The principal's directory object. + #[must_use] + pub fn principal(&self) -> &Guid128 { + &self.principal + } + + /// The roles held, sorted. + #[must_use] + pub fn roles(&self) -> &[RoleId] { + &self.roles + } + + /// The security groups of the principal whose grants were never read. + /// They granted nothing; a caller that needs every grant can refuse when + /// this is not empty. + #[must_use] + pub fn unread_groups(&self) -> &[Guid128] { + &self.unread + } +} + +impl ActorSource for ViewActors { + fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId] { + if actor == self.subject { + &self.roles + } else { + &[] + } + } +} diff --git a/crates/lance-graph-dir-sim/src/image.rs b/crates/lance-graph-dir-sim/src/image.rs new file mode 100644 index 000000000..d44efff70 --- /dev/null +++ b/crates/lance-graph-dir-sim/src/image.rs @@ -0,0 +1,68 @@ +//! A version history as owned data: what persisting a [`VersionStore`] +//! writes, and what restoring one reads. +//! +//! The image holds the store's inputs, never its derived planes: +//! +//! * each root as the [`Observation`] it was built from. Snapshot lanes are +//! derived and lossy (a nickname keeps only its comparison form there), and +//! their layout grows as planes are added; +//! * the dictionary as raw values in [`ValueId`](crate::ValueId) order. +//! Comparison keys and routing aliases are recomputed by interning, so the +//! same order reproduces every id the changes refer to; +//! * every [`Version`] verbatim: a simulated version's delta is what the +//! rule produced, so restoring never re-runs a rule; +//! * tags and recorded verdicts. +//! +//! No I/O here: a storage crate writes and reads the image. +//! +//! [`VersionStore`]: crate::VersionStore + +use crate::snapshot::{BuildError, Observation}; +use crate::view::ApplyError; +use ogar_dir_sim::{Version, VersionId, Violation}; + +/// A version history as owned data ([`crate::VersionStore::image`], +/// [`crate::VersionStore::restore`]). +#[derive(Clone, Debug, Default)] +pub struct StoreImage { + /// The dictionary: raw values in id order. + pub values: Vec, + /// Each root and the observation it was built from, by version id. + pub roots: Vec<(VersionId, Observation)>, + /// Every version, in id order. + pub versions: Vec, + /// Tags, sorted by name. + pub tags: Vec<(String, VersionId)>, + /// Recorded verdicts, by version id. + pub verdicts: Vec<(VersionId, Vec)>, +} + +/// Why an image could not be restored. Nothing is half-restored: the error +/// is returned instead of a store. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RestoreError { + /// Value `index` appears twice in the dictionary, so ids would shift. + DuplicateValue(usize), + /// Versions must be listed densely in id order; `index` holds another id. + VersionOrder { + /// Position in the list. + index: usize, + /// The id found there. + found: VersionId, + }, + /// A root version without its observation. + MissingRoot(VersionId), + /// An observation for a version that is not a root. + NotARoot(VersionId), + /// A version derived from a version listed after it, or not at all. + UnknownParent(VersionId), + /// A root's observation does not build. + Build(VersionId, BuildError), + /// A version's delta does not apply to its parent. + Apply(VersionId, ApplyError), + /// Building the roots interned a value the dictionary did not list, so + /// the image's ids are not the ones its changes were made with. + IncompleteDictionary, + /// A tag or verdict names a version the image does not hold. + UnknownVersion(VersionId), +} diff --git a/crates/lance-graph-dir-sim/src/lib.rs b/crates/lance-graph-dir-sim/src/lib.rs index 4bc33dd63..964b34f7b 100644 --- a/crates/lance-graph-dir-sim/src/lib.rs +++ b/crates/lance-graph-dir-sim/src/lib.rs @@ -16,12 +16,15 @@ //! * [`validate`] — invariants as Quack programs (`Semijoin` anti-joins, //! `GroupReduce` counts). //! * [`store`] — append-only versions, tags, diff, plan, audit. +//! * [`image`] — a history as owned data, to persist and restore. //! * [`observe`] — `ogar-ad` records → observation (`OuHhtl` → `Dn128`, //! failing closed). //! * [`ad`] — a version as Active Directory entries (observed, mirrored, //! synthetic or simulated) and as LDIF. //! * [`ldap`] — a read-only LDAP v3 protocol handler over a projection; //! access decided by the host's [`ldap::Authority`]. Bytes in, bytes out. +//! * `actors` (feature `rbac`) — the roles a principal holds through the +//! security groups whose SID it carries, as the contract's `ActorSource`. //! * [`cloud`] — which AD objects have their mailbox in Exchange Online, //! read from OGAR's GUID-keyed hybrid correspondence fold. //! @@ -31,11 +34,14 @@ #![forbid(unsafe_code)] +#[cfg(feature = "rbac")] +pub mod actors; pub mod ad; pub mod bind; pub mod cloud; mod exec; pub mod groups; +pub mod image; pub mod ldap; pub mod observe; pub mod proxy; diff --git a/crates/lance-graph-dir-sim/src/observe.rs b/crates/lance-graph-dir-sim/src/observe.rs index 6fb2864f3..cb42df65a 100644 --- a/crates/lance-graph-dir-sim/src/observe.rs +++ b/crates/lance-graph-dir-sim/src/observe.rs @@ -202,8 +202,19 @@ pub struct GraphObservation { pub synthetic: Vec, } -/// Graph `user` records (`ogar-az`) in `tenant`, as an observation. Other -/// record kinds are skipped. `dict` must be the dictionary the records were +/// One group's direct members as Graph listed them +/// (`ogar_az::group::ingest_members_page`, users and nested groups alike). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GroupMembers { + /// The group (Graph `id`). + pub group: Guid128, + /// Its direct members (Graph `id`s). Nesting is walked by the view. + pub members: Vec, +} + +/// Graph `user` and `group` records (`ogar-az`) in `tenant`, with the +/// groups' direct `memberships`, as an observation. Other record kinds are +/// skipped. `dict` must be the dictionary the records were /// ingested with (the on-premises domain's, so a mirrored user's OU equals /// its AD object's); the synthetic container is added to it. /// @@ -211,9 +222,16 @@ pub struct GraphObservation { /// `msExchMailboxGuid`, so `recipient` is not read (`None`) and /// `exchange_guid` is `None`. `accountEnabled` gives the flag; absent is /// unknown, never enabled. +/// +/// A group carries no location (Graph reports no on-premises DN for one), +/// its `securityEnabled` flag as read, and an address only when +/// `mailEnabled` was read as `true`: the primary SMTP proxy, else `mail`. +/// A membership is kept whatever its ends: one naming an object that was +/// not read is a dangling observation, reported by validation. pub fn from_graph( tenant: DirectoryScope, records: &[DirRecord], + memberships: &[GroupMembers], pool: &ValuePool, dict: &mut OuDictionary, ) -> Result { @@ -238,17 +256,26 @@ pub fn from_graph( }, synthetic: Vec::new(), }; - for r in records.iter().filter(|r| { - r.schema().family == SchemaFamily::MsGraph - && r.object_kind() == ogar_az::AzKind::User as u16 - }) { + for r in records + .iter() + .filter(|r| r.schema().family == SchemaFamily::MsGraph) + { let node = r.node_guid(); + let kind = r.object_kind(); + let is_user = kind == ogar_az::AzKind::User as u16; + if !is_user && kind != ogar_az::AzKind::Group as u16 { + continue; + } if r.scope_guid() != tenant.0 { return Err(ObserveError::ForeignScope { node, scope: DirectoryScope(r.scope_guid()), }); } + if !is_user { + out.observation.nodes.push((node, graph_group(r, pool))); + continue; + } let hybrid = ogar_az::attr_str(r, pool, "onPremisesDistinguishedName").is_some(); let dn = match (hybrid, r.ou_hhtl()) { (true, Some(h)) => { @@ -291,6 +318,34 @@ pub fn from_graph( }, )); } + for m in memberships { + out.observation + .members + .extend(m.members.iter().map(|&member| (member, m.group))); + } out.synthetic.sort_unstable(); Ok(out) } + +fn graph_group(r: &DirRecord, pool: &ValuePool) -> ObservedNode { + use ogar_az::group::{group_flag, group_multi, group_str}; + let mut n = ObservedNode::group(); + let mut proxies: Vec = group_multi(r, pool, "proxyAddresses") + .unwrap_or_default() + .into_iter() + .map(str::to_string) + .collect(); + let primary = proxies + .iter() + .position(|v| v.starts_with("SMTP:")) + .map(|i| proxies.remove(i)["SMTP:".len()..].to_string()); + let mail = group_str(r, pool, "mail").map(str::to_string); + if group_flag(r, "mailEnabled") == Some(true) { + n.primary_smtp = primary.or_else(|| mail.clone()); + n.proxies = proxies; + } + n.mail = mail; + n.alias = group_str(r, pool, "mailNickname").map(str::to_string); + n.security = group_flag(r, "securityEnabled"); + n +} diff --git a/crates/lance-graph-dir-sim/src/snapshot.rs b/crates/lance-graph-dir-sim/src/snapshot.rs index bf2eabe87..2d94f03fd 100644 --- a/crates/lance-graph-dir-sim/src/snapshot.rs +++ b/crates/lance-graph-dir-sim/src/snapshot.rs @@ -200,6 +200,15 @@ impl Dicts { bump(&self.counters.resolutions); self.values.resolve(v.0) } + /// Every raw value, in `ValueId` order. Interning them in this order + /// into an empty dictionary reproduces every value and key id. + pub fn values_in_order(&self) -> impl ExactSizeIterator { + self.values.strings.iter().map(String::as_str) + } + /// Number of raw values issued. + pub fn value_count(&self) -> usize { + self.values.len() + } /// The comparison key of a value. pub fn key_of(&self, v: ValueId) -> Option { self.key_of.get(v.0 as usize).map(|&k| KeyId(k)) diff --git a/crates/lance-graph-dir-sim/src/store.rs b/crates/lance-graph-dir-sim/src/store.rs index b3b66f00f..42e688f17 100644 --- a/crates/lance-graph-dir-sim/src/store.rs +++ b/crates/lance-graph-dir-sim/src/store.rs @@ -8,6 +8,7 @@ //! * Versions, snapshots and dictionaries are append-only. Simulating, //! validating or rejecting a version cannot alter another. +use crate::image::{RestoreError, StoreImage}; use crate::rule::Rule; use crate::snapshot::{BuildError, Dicts, Observation, Snapshot}; use crate::validate::validate; @@ -15,7 +16,7 @@ use crate::view::{ApplyError, Overlay, View}; use ogar_dir_core::Guid128; use ogar_dir_sim::{ Attribute, Change, EvidenceRef, ExecutionPlan, KeyId, NodeKind, NodeState, Origin, PlanError, - ValueId, Version, VersionId, Violation, TAG_DESIRED, TAG_OBSERVED, + RuleId, ValueId, Version, VersionId, Violation, TAG_DESIRED, TAG_OBSERVED, }; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -43,6 +44,12 @@ pub struct Rejection { pub violations: Vec, } +/// The rule a [`VersionStore::revert`] is recorded under. +pub const REVERT: RuleId = RuleId { + name: "Revert", + version: 1, +}; + /// Append-only version store. #[derive(Debug, Default)] pub struct VersionStore { @@ -51,6 +58,9 @@ pub struct VersionStore { versions: Vec, tags: BTreeMap, verdicts: BTreeMap>, + /// Each root's observation as recorded: the persisted form of a root + /// ([`crate::image`]), since its snapshot lanes are derived. + observations: BTreeMap, } impl VersionStore { @@ -97,8 +107,9 @@ impl VersionStore { observed_at_ms: i64, obs: Observation, ) -> Result { - let snap = Snapshot::build(obs, &mut self.dicts)?; + let snap = Snapshot::build(obs.clone(), &mut self.dicts)?; let id = self.next_id(); + self.observations.insert(id, obs); self.versions.push(Version { id, parent: None, @@ -185,6 +196,37 @@ impl VersionStore { Ok(id) } + /// Undo back to `earlier`: a new version on top of `current` whose + /// delta is `diff(current, earlier)`, so its reads equal `earlier`'s. + /// History is never rewritten; the revert is recorded like any other + /// simulation, under [`REVERT`] with `version:` as its first + /// evidence. + /// + /// # Errors + /// + /// [`SimError::ScopeMismatch`] across directories, + /// [`SimError::EmptyProposal`] when `current` already reads as `earlier`. + pub fn revert( + &mut self, + current: VersionId, + earlier: VersionId, + evidence: &[EvidenceRef], + ) -> Result { + struct Fixed(Vec); + impl Rule for Fixed { + fn id(&self) -> RuleId { + REVERT + } + fn propose(&self, _: &View<'_>, _: &[EvidenceRef]) -> Vec { + self.0.clone() + } + } + let delta = self.diff(current, earlier)?; + let mut ev = vec![EvidenceRef(format!("version:{}", earlier.0))]; + ev.extend_from_slice(evidence); + self.simulate(current, &Fixed(delta), &ev) + } + /// Validate `v` and record the verdict. Empty = valid. pub fn validate(&mut self, v: VersionId) -> Result, SimError> { let violations = validate(&self.view(v)?); @@ -213,6 +255,89 @@ impl VersionStore { Ok(()) } + /// The whole history as owned data ([`crate::image`]): what a storage + /// crate writes. + pub fn image(&self) -> StoreImage { + StoreImage { + values: self.dicts.values_in_order().map(str::to_string).collect(), + roots: self + .observations + .iter() + .map(|(v, o)| (*v, o.clone())) + .collect(), + versions: self.versions.clone(), + tags: self.tags.iter().map(|(n, v)| (n.clone(), *v)).collect(), + verdicts: self.verdicts.iter().map(|(v, x)| (*v, x.clone())).collect(), + } + } + + /// A store from an image: the dictionary interned in order, each root + /// rebuilt from its observation, each version re-applied to its parent. + /// The result answers every read exactly as the store the image was + /// taken from. + /// + /// # Errors + /// + /// [`RestoreError`]: the image is not one a store could have produced. + pub fn restore(image: StoreImage) -> Result { + let mut st = Self::new(); + for (i, v) in image.values.iter().enumerate() { + if st.dicts.intern(v).0 as usize != i { + return Err(RestoreError::DuplicateValue(i)); + } + } + let mut roots: BTreeMap = image.roots.into_iter().collect(); + for (i, v) in image.versions.into_iter().enumerate() { + if v.id != VersionId(i as u64) { + return Err(RestoreError::VersionOrder { + index: i, + found: v.id, + }); + } + match v.parent { + None => { + let obs = roots.remove(&v.id).ok_or(RestoreError::MissingRoot(v.id))?; + let snap = Snapshot::build(obs.clone(), &mut st.dicts) + .map_err(|e| RestoreError::Build(v.id, e))?; + // A root interns only values the dictionary listed: any + // other would take an id a later change means otherwise. + if st.dicts.value_count() != image.values.len() { + return Err(RestoreError::IncompleteDictionary); + } + st.roots.insert(v.id, Arc::new(snap)); + st.observations.insert(v.id, obs); + } + Some(p) => { + if p >= v.id { + return Err(RestoreError::UnknownParent(v.id)); + } + let mut view = st.view(p).map_err(|_| RestoreError::UnknownParent(v.id))?; + for c in &v.delta { + view.apply(c).map_err(|e| RestoreError::Apply(v.id, e))?; + } + } + } + st.versions.push(v); + } + if let Some((&v, _)) = roots.iter().next() { + return Err(RestoreError::NotARoot(v)); + } + let known = |v: VersionId| (v.0 as usize) < st.versions.len(); + for (name, v) in image.tags { + if !known(v) { + return Err(RestoreError::UnknownVersion(v)); + } + st.tags.insert(name, v); + } + for (v, x) in image.verdicts { + if !known(v) { + return Err(RestoreError::UnknownVersion(v)); + } + st.verdicts.insert(v, x); + } + Ok(st) + } + /// Version a tag points to. pub fn tag(&self, name: &str) -> Option { self.tags.get(name).copied() diff --git a/crates/lance-graph-dir-sim/tests/ad_projection.rs b/crates/lance-graph-dir-sim/tests/ad_projection.rs index 8182b1374..7a780a940 100644 --- a/crates/lance-graph-dir-sim/tests/ad_projection.rs +++ b/crates/lance-graph-dir-sim/tests/ad_projection.rs @@ -44,7 +44,7 @@ fn cloud_store() -> (VersionStore, VersionId, OuDictionary, Vec) { let recs = ogar_az::ingest_page(&body, g(TENANT), &mut dict, &mut pool, 0) .unwrap() .records; - let out = from_graph(DirectoryScope(g(TENANT)), &recs, &pool, &mut dict).unwrap(); + let out = from_graph(DirectoryScope(g(TENANT)), &recs, &[], &pool, &mut dict).unwrap(); let mut st = VersionStore::new(); let v = st.observe("graph", 0, out.observation).unwrap(); (st, v, dict, out.synthetic) diff --git a/crates/lance-graph-dir-sim/tests/graph_observe.rs b/crates/lance-graph-dir-sim/tests/graph_observe.rs index f46d95e21..8ab37eb4c 100644 --- a/crates/lance-graph-dir-sim/tests/graph_observe.rs +++ b/crates/lance-graph-dir-sim/tests/graph_observe.rs @@ -48,7 +48,7 @@ fn a_synchronized_user_is_mirrored_and_a_cloud_only_user_is_synthetic() { &mut dict, &mut pool, ); - let out = from_graph(DirectoryScope(g(TENANT)), &recs, &pool, &mut dict).unwrap(); + let out = from_graph(DirectoryScope(g(TENANT)), &recs, &[], &pool, &mut dict).unwrap(); assert_eq!(out.synthetic, vec![g(CLOUD)]); let node = |id| { &out.observation @@ -89,7 +89,7 @@ fn an_unread_account_flag_stays_unknown() { let mut dict = OuDictionary::new(); let mut pool = ValuePool::new(); let recs = ingest(&[user(CLOUD, "")], &mut dict, &mut pool); - let out = from_graph(DirectoryScope(g(TENANT)), &recs, &pool, &mut dict).unwrap(); + let out = from_graph(DirectoryScope(g(TENANT)), &recs, &[], &pool, &mut dict).unwrap(); assert_eq!(out.observation.nodes[0].1.active, None); } @@ -104,7 +104,7 @@ fn a_mirrored_user_inside_the_synthetic_container_is_refused() { ); let recs = ingest(&[user(HYBRID, &dn)], &mut dict, &mut pool); assert_eq!( - from_graph(DirectoryScope(g(TENANT)), &recs, &pool, &mut dict).unwrap_err(), + from_graph(DirectoryScope(g(TENANT)), &recs, &[], &pool, &mut dict).unwrap_err(), ObserveError::SyntheticClash { node: g(HYBRID) } ); } @@ -121,7 +121,7 @@ fn an_unencoded_on_premises_dn_is_refused_not_made_synthetic() { &mut pool, ); assert_eq!( - from_graph(DirectoryScope(g(TENANT)), &recs, &pool, &mut dict).unwrap_err(), + from_graph(DirectoryScope(g(TENANT)), &recs, &[], &pool, &mut dict).unwrap_err(), ObserveError::UnplacedMirror { node: g(HYBRID) } ); } @@ -133,7 +133,7 @@ fn a_record_of_another_tenant_is_refused() { let recs = ingest(&[user(CLOUD, "")], &mut dict, &mut pool); let other = g("c0ffee00-1234-4abc-8def-000000000043"); assert!(matches!( - from_graph(DirectoryScope(other), &recs, &pool, &mut dict), + from_graph(DirectoryScope(other), &recs, &[], &pool, &mut dict), Err(ObserveError::ForeignScope { .. }) )); } @@ -154,7 +154,95 @@ fn mailbox_records_are_not_observed_as_users() { 0, ) .unwrap(); - let out = from_graph(DirectoryScope(g(TENANT)), &[mbx], &pool, &mut dict).unwrap(); + let out = from_graph(DirectoryScope(g(TENANT)), &[mbx], &[], &pool, &mut dict).unwrap(); assert!(out.observation.nodes.is_empty()); assert!(out.synthetic.is_empty()); } + +const SEC: &str = "a1a1a1a1-0000-4000-8000-000000000001"; +const DL: &str = "d1d1d1d1-0000-4000-8000-000000000002"; +const PLAIN: &str = "e1e1e1e1-0000-4000-8000-000000000003"; + +// Graph groups and their direct members become groups and memberships of +// the observation; nesting and the SID chain are then the view's walk. +#[test] +fn graph_groups_and_memberships_are_observed() { + use lance_graph_dir_sim::observe::GroupMembers; + use lance_graph_dir_sim::VersionStore; + let mut dict = OuDictionary::new(); + let mut pool = ValuePool::new(); + let mut recs = ingest( + &[user(HYBRID, HYBRID_DN), user(CLOUD, "")], + &mut dict, + &mut pool, + ); + let groups = format!( + r#"{{"value":[ + {{"id":"{SEC}","mail":"fin@example.de","mailNickname":"fin","mailEnabled":true,"securityEnabled":true, + "proxyAddresses":["smtp:f@example.de","SMTP:finance@example.de"]}}, + {{"id":"{DL}","mail":"all@example.de","mailEnabled":true,"securityEnabled":false}}, + {{"id":"{PLAIN}","mail":"left@example.de","mailEnabled":false,"securityEnabled":null}}]}}"# + ); + recs.extend( + ogar_az::group::ingest_groups_page(&groups, g(TENANT), &mut pool, 0) + .unwrap() + .records, + ); + // SEC ⊃ {HYBRID, DL}; DL ⊃ {CLOUD}; PLAIN ⊃ {HYBRID}. + let memberships = [ + GroupMembers { + group: g(SEC), + members: vec![g(HYBRID), g(DL)], + }, + GroupMembers { + group: g(DL), + members: vec![g(CLOUD)], + }, + GroupMembers { + group: g(PLAIN), + members: vec![g(HYBRID)], + }, + ]; + let out = from_graph( + DirectoryScope(g(TENANT)), + &recs, + &memberships, + &pool, + &mut dict, + ) + .unwrap(); + let node = |id| { + out.observation + .nodes + .iter() + .find(|(n, _)| *n == g(id)) + .unwrap() + .1 + .clone() + }; + // The primary proxy is the address; `mail` only when there is none. + assert_eq!( + node(SEC).primary_smtp.as_deref(), + Some("finance@example.de") + ); + assert_eq!(node(DL).primary_smtp.as_deref(), Some("all@example.de")); + // Not mail-enabled: no address, whatever `mail` still says. + assert_eq!(node(PLAIN).primary_smtp, None); + assert_eq!(node(PLAIN).mail.as_deref(), Some("left@example.de")); + assert_eq!(node(PLAIN).dn, None); + assert_eq!(out.observation.members.len(), 4); + + let mut st = VersionStore::new(); + let v = st.observe("graph", 1, out.observation).unwrap(); + let view = st.view(v).unwrap(); + assert!(view.is_mail_recipient(&g(SEC))); + assert!(!view.is_mail_recipient(&g(PLAIN))); + assert_eq!(view.is_security_enabled(&g(SEC)), Some(true)); + assert_eq!(view.is_security_enabled(&g(DL)), Some(false)); + assert_eq!(view.is_security_enabled(&g(PLAIN)), None, "null is unknown"); + // CLOUD reaches SEC only through the distribution group, so it holds no + // SID; HYBRID holds SEC's directly. PLAIN's flag was not read. + assert_eq!(view.groups_transitive(&g(CLOUD)), vec![g(SEC), g(DL)]); + assert!(view.security_identifiers(&g(CLOUD)).is_empty()); + assert_eq!(view.security_identifiers(&g(HYBRID)), vec![g(SEC)]); +} diff --git a/crates/lance-graph-dir-sim/tests/ldap_alloc.rs b/crates/lance-graph-dir-sim/tests/ldap_alloc.rs index b06d745de..f6747160b 100644 --- a/crates/lance-graph-dir-sim/tests/ldap_alloc.rs +++ b/crates/lance-graph-dir-sim/tests/ldap_alloc.rs @@ -49,7 +49,7 @@ fn directory() -> Directory { let recs = ogar_az::ingest_page(body, tenant, &mut dict, &mut pool, 0) .unwrap() .records; - let out = from_graph(DirectoryScope(tenant), &recs, &pool, &mut dict).unwrap(); + let out = from_graph(DirectoryScope(tenant), &recs, &[], &pool, &mut dict).unwrap(); let mut st = VersionStore::new(); let v = st.observe("graph", 0, out.observation).unwrap(); let p = project( diff --git a/crates/lance-graph-dir-sim/tests/ldap_server.rs b/crates/lance-graph-dir-sim/tests/ldap_server.rs index 19c9cfaa3..72637655e 100644 --- a/crates/lance-graph-dir-sim/tests/ldap_server.rs +++ b/crates/lance-graph-dir-sim/tests/ldap_server.rs @@ -29,7 +29,7 @@ fn directory() -> Directory { let recs = ogar_az::ingest_page(&body, g(TENANT), &mut dict, &mut pool, 0) .unwrap() .records; - let out = from_graph(DirectoryScope(g(TENANT)), &recs, &pool, &mut dict).unwrap(); + let out = from_graph(DirectoryScope(g(TENANT)), &recs, &[], &pool, &mut dict).unwrap(); let mut st = VersionStore::new(); let v = st.observe("graph", 0, out.observation).unwrap(); let p = project( diff --git a/crates/lance-graph-dir-sim/tests/persist.rs b/crates/lance-graph-dir-sim/tests/persist.rs new file mode 100644 index 000000000..7d0a135dc --- /dev/null +++ b/crates/lance-graph-dir-sim/tests/persist.rs @@ -0,0 +1,227 @@ +//! A history persists as its inputs (`image`) and restores to a store that +//! answers every read the same way (W-3, plan `dir-sim-persist-v1`). + +use lance_graph_dir_sim::image::RestoreError; +use lance_graph_dir_sim::*; +use ogar_dir_core::{DirectoryScope, Guid128}; +use ogar_dir_sim::*; + +fn g(n: u8) -> Guid128 { + Guid128([n; 16]) +} +const ALICE: u8 = 0xA1; +const BOB: u8 = 0xB0; +const EMPLOYEES: u8 = 0xE0; +const EXCHANGE: u8 = 0xEC; + +const EXCHANGE_ACCESS: RuleId = RuleId { + name: "ExchangeAccess", + version: 1, +}; +const EMPLOYEES_GET_EXCHANGE: RuleId = RuleId { + name: "EmployeesGetExchange", + version: 1, +}; +const RENAME_MAIL: RuleId = RuleId { + name: "RenameMail", + version: 1, +}; + +const SCOPE: DirectoryScope = DirectoryScope(Guid128([0x5C; 16])); + +fn observed() -> Observation { + Observation { + scope: SCOPE, + nodes: vec![ + ( + g(ALICE), + ObservedNode::user("alice@example.test", "alice@example.test"), + ), + ( + g(BOB), + ObservedNode::user("bob@example.test", "bob@example.test"), + ), + (g(EMPLOYEES), ObservedNode::group()), + (g(EXCHANGE), ObservedNode::group()), + ], + members: vec![(g(ALICE), g(EMPLOYEES)), (g(BOB), g(EMPLOYEES))], + } +} +fn grant_alice() -> GrantGroup { + GrantGroup { + rule: EXCHANGE_ACCESS, + group: g(EXCHANGE), + to: vec![g(ALICE)], + } +} +fn imply() -> ImplyGroup { + ImplyGroup { + rule: EMPLOYEES_GET_EXCHANGE, + source: g(EMPLOYEES), + target: g(EXCHANGE), + } +} +fn ev(s: &str) -> Vec { + vec![EvidenceRef(s.into())] +} +fn chain_from(obs: Observation) -> (VersionStore, VersionId, VersionId, VersionId) { + let mut st = VersionStore::new(); + let g0 = st.observe("lab", 1_000, obs).unwrap(); + let g1 = st.simulate(g0, &grant_alice(), &ev("REQ-1")).unwrap(); + let g2 = st.simulate(g1, &imply(), &ev("POLICY-7")).unwrap(); + (st, g0, g1, g2) +} + +// g0 observed; g1, g2 simulated and g2 desired; a value interned between +// versions; g3 rejected (a verdict); g4 a second observation. +fn history() -> (VersionStore, Vec, ValueId) { + let (mut st, g0, g1, g2) = chain_from(observed()); + st.promote_desired(g2).unwrap(); + let to = st.intern("Alice@Example.test"); + let g3 = st + .simulate( + g2, + &SetPrimarySmtp { + rule: RENAME_MAIL, + user: g(BOB), + to, + }, + &ev("REQ-2"), + ) + .unwrap(); + assert!(st.promote_desired(g3).is_err()); + let mut later = observed(); + later.members.pop(); + later.nodes.push(( + g(0xC0), + ObservedNode::user("carol@example.test", "carol@example.test"), + )); + let g4 = st.observe("lab", 2_000, later).unwrap(); + (st, vec![g0, g1, g2, g3, g4], to) +} + +fn reads(st: &VersionStore, vs: &[VersionId]) -> String { + let mut out = String::new(); + for &v in vs { + out += &format!( + "{v:?} {:?} {:?} {:?} {:?}\n", + st.version(v), + st.diff(vs[0], v), + st.verdict(v), + st.view(v).unwrap().members_transitive(&g(EMPLOYEES)), + ); + } + out += &format!( + "{:?} {:?} {:?}\n", + st.tag(TAG_DESIRED), + st.tag(TAG_OBSERVED), + st.plan(vs[2]) + ); + out +} + +#[test] +fn a_history_restores_to_the_same_reads() { + let (st, vs, to) = history(); + let image = st.image(); + assert_eq!(image.versions.len(), 5); + assert_eq!(image.roots.len(), 2); + let back = VersionStore::restore(image.clone()).unwrap(); + assert_eq!(reads(&back, &vs), reads(&st, &vs)); + assert_eq!(back.value(to), Some("Alice@Example.test")); + // The image of the restored store is the image it came from. + assert_eq!(format!("{:?}", back.image()), format!("{image:?}")); + // Work continues on the restored store with the same ids. + let mut back = back; + assert_eq!(back.intern("Alice@Example.test"), to); +} + +#[test] +fn a_damaged_image_is_refused() { + let (st, _, _) = history(); + let image = st.image(); + + let mut i = image.clone(); + i.values.push(i.values[0].clone()); + assert_eq!( + VersionStore::restore(i).unwrap_err(), + RestoreError::DuplicateValue(image.values.len()) + ); + + let mut i = image.clone(); + i.values.clear(); + assert_eq!( + VersionStore::restore(i).unwrap_err(), + RestoreError::IncompleteDictionary + ); + + let mut i = image.clone(); + let root = i.roots.remove(1).0; + assert_eq!( + VersionStore::restore(i).unwrap_err(), + RestoreError::MissingRoot(root) + ); + + let mut i = image.clone(); + i.versions.swap(1, 2); + assert!(matches!( + VersionStore::restore(i).unwrap_err(), + RestoreError::VersionOrder { index: 1, .. } + )); + + let mut i = image.clone(); + i.tags.push(("ghost".into(), VersionId(99))); + assert_eq!( + VersionStore::restore(i).unwrap_err(), + RestoreError::UnknownVersion(VersionId(99)) + ); + + let mut i = image.clone(); + let extra = (VersionId(1), i.roots[0].1.clone()); + i.roots.push(extra); + assert_eq!( + VersionStore::restore(i).unwrap_err(), + RestoreError::NotARoot(VersionId(1)) + ); + + // A delta that no longer applies to its parent. + let mut i = image.clone(); + let d = i.versions[1].delta.clone(); + i.versions[2].delta.extend(d); + assert!(matches!( + VersionStore::restore(i).unwrap_err(), + RestoreError::Apply(VersionId(2), _) + )); +} + +// Undo is a new version whose reads equal the earlier one's; nothing before +// it changes. +#[test] +fn a_revert_is_a_new_version_that_reads_as_the_earlier_one() { + let (mut st, vs, _) = history(); + let (g0, g3) = (vs[0], vs[3]); + let before = reads(&st, &vs); + let r = st.revert(g3, g0, &ev("UNDO-1")).unwrap(); + assert_eq!(st.diff(r, g0).unwrap(), vec![]); + assert_ne!(st.diff(g3, g0).unwrap(), vec![]); + assert_eq!(st.version(r).unwrap().parent, Some(g3)); + assert_eq!( + st.version(r).unwrap().origin, + Origin::Simulated { + rule: lance_graph_dir_sim::store::REVERT, + evidence: vec![ + EvidenceRef("version:0".into()), + EvidenceRef("UNDO-1".into()) + ], + } + ); + assert_eq!(reads(&st, &vs), before, "history is not rewritten"); + // Nothing to undo: no version. + assert!(matches!( + st.revert(r, g0, &[]), + Err(SimError::EmptyProposal(_)) + )); + // A revert persists like any version. + let back = VersionStore::restore(st.image()).unwrap(); + assert_eq!(back.diff(r, g0).unwrap(), vec![]); +} diff --git a/crates/lance-graph-dir-sim/tests/view_actors.rs b/crates/lance-graph-dir-sim/tests/view_actors.rs new file mode 100644 index 000000000..c24e8f7c2 --- /dev/null +++ b/crates/lance-graph-dir-sim/tests/view_actors.rs @@ -0,0 +1,163 @@ +//! The roles a principal holds through a view (`actors`, feature `rbac`): +//! granted to security groups by GUID, inherited through SID chains only, +//! filtered by the consumer's binding, nothing from an unread group. + +use lance_graph_contract::rbac_plug::{ActorSource, RbacBinding}; +use lance_graph_dir_sim::actors::{GroupRoles, ViewActors}; +use lance_graph_dir_sim::*; +use ogar_dir_core::{DirectoryScope, Guid128}; + +fn g(n: u8) -> Guid128 { + Guid128([n; 16]) +} +const SCOPE: DirectoryScope = DirectoryScope(Guid128([0x5C; 16])); + +const U1: u8 = 0x11; +const U2: u8 = 0x12; +const U3: u8 = 0x13; +const U4: u8 = 0x14; +const U5: u8 = 0x15; +/// Mail-enabled security group. +const S1: u8 = 0xA1; +/// Security group, not mail-enabled. +const S2: u8 = 0xA2; +/// Mail-enabled security group, reached only through D1. +const S3: u8 = 0xA3; +/// Distribution group: mail-enabled, not security-enabled. +const D1: u8 = 0xD1; +/// Mail-enabled group whose security flag was not read. +const UNREAD: u8 = 0xE1; + +fn group(smtp: Option<&str>, security: Option) -> ObservedNode { + let mut n = ObservedNode::group(); + n.primary_smtp = smtp.map(str::to_string); + n.security = security; + n +} + +// S1 ⊃ {u1, S2, D1, UNREAD}; S2 ⊃ {u2, S1} (a cycle); D1 ⊃ {u3, S3}; +// S3 ⊃ {u4}; UNREAD ⊃ {u5}. +fn observed() -> Observation { + let user = |n: &str| ObservedNode::user(&format!("{n}@x.test"), &format!("{n}@x.test")); + Observation { + scope: SCOPE, + nodes: vec![ + (g(U1), user("u1")), + (g(U2), user("u2")), + (g(U3), user("u3")), + (g(U4), user("u4")), + (g(U5), user("u5")), + (g(S1), group(Some("s1@x.test"), Some(true))), + (g(S2), group(None, Some(true))), + (g(S3), group(Some("s3@x.test"), Some(true))), + (g(D1), group(Some("d1@x.test"), Some(false))), + (g(UNREAD), group(Some("unread@x.test"), None)), + ], + members: vec![ + (g(U1), g(S1)), + (g(S2), g(S1)), + (g(D1), g(S1)), + (g(UNREAD), g(S1)), + (g(U2), g(S2)), + (g(S1), g(S2)), + (g(U3), g(D1)), + (g(S3), g(D1)), + (g(U4), g(S3)), + (g(U5), g(UNREAD)), + ], + } +} + +fn binding(roles: &[&'static str]) -> RbacBinding { + RbacBinding::new( + "test", + vec![], + roles.iter().map(|r| (*r, vec![])).collect(), + vec![], + ) +} + +// S1 grants `reader`, S2 `editor` and a role the plug does not declare, D1 +// (a distribution group) `dl`; S3's grants were never read. +fn grants() -> GroupRoles { + let mut gr = GroupRoles::new(); + gr.read(g(S1), &["reader"]) + .read(g(S2), &["editor", "undeclared"]) + .read(g(D1), &["dl"]) + .read(g(UNREAD), &["reader"]); + gr +} + +const DECLARED: &[&str] = &["reader", "editor", "dl"]; + +#[test] +fn roles_come_from_the_security_groups_a_principal_carries() { + let mut st = VersionStore::new(); + let v = st.observe("lab", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + let b = binding(DECLARED); + // u1 is in S1 directly and in S2 through S1: both grants, the undeclared + // role dropped. + let a = ViewActors::for_principal(&view, &g(U1), &grants(), &b); + assert_eq!(a.roles(), &["editor", "reader"]); + assert_eq!(a.roles_of(a.subject()), &["editor", "reader"]); + assert!(a.unread_groups().is_empty()); + // u3 is in D1, nested in S1: D1 has no SID, so neither D1's grant nor + // S1's reaches u3. + let a = ViewActors::for_principal(&view, &g(U3), &grants(), &b); + assert!(a.roles().is_empty()); + // u5 is in the group whose security flag was not read: no SID, no role. + let a = ViewActors::for_principal(&view, &g(U5), &grants(), &b); + assert!(a.roles().is_empty()); +} + +#[test] +fn an_unread_group_grants_nothing_and_is_named() { + let mut st = VersionStore::new(); + let v = st.observe("lab", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + // u4 carries only S3's SID, whose grants were never read. + let a = ViewActors::for_principal(&view, &g(U4), &grants(), &binding(DECLARED)); + assert!(a.roles().is_empty()); + assert_eq!(a.unread_groups(), &[g(S3)]); + // Read with no grants is not the same as unread. + let mut gr = grants(); + gr.read(g(S3), &[]); + let a = ViewActors::for_principal(&view, &g(U4), &gr, &binding(DECLARED)); + assert!(a.roles().is_empty()); + assert!(a.unread_groups().is_empty()); +} + +#[test] +fn the_binding_decides_which_roles_survive() { + let mut st = VersionStore::new(); + let v = st.observe("lab", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + // Declaring the role lets the same grant through: the filter binds. + let a = ViewActors::for_principal( + &view, + &g(U2), + &grants(), + &binding(&["editor", "undeclared"]), + ); + assert_eq!(a.roles(), &["editor", "undeclared"]); + let a = ViewActors::for_principal(&view, &g(U2), &grants(), &binding(&["reader"])); + assert_eq!(a.roles(), &["reader"]); +} + +#[test] +fn only_the_principal_holds_roles() { + let mut st = VersionStore::new(); + let v = st.observe("lab", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + let b = binding(DECLARED); + let a = ViewActors::for_principal(&view, &g(U1), &grants(), &b); + assert_eq!(a.subject(), g(U1).to_string()); + assert!(a.roles_of(&g(U2).to_string()).is_empty()); + assert!(a.roles_of("").is_empty()); + // Not a user in the view: nothing. + let a = ViewActors::for_principal(&view, &g(0x99), &grants(), &b); + assert!(a.roles().is_empty()); + let a = ViewActors::for_principal(&view, &g(S1), &grants(), &b); + assert!(a.roles().is_empty()); +}