diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index db241a02b..564ee6472 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -46,9 +46,10 @@ Plan: `.claude/plans/dir-sim-persist-v1.md`. | **D-DSP-0** | `StoreImage` + `VersionStore::image` / `restore` (dir-sim, no I/O) | Shipped (#1473) | 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 | Shipped (#1473) | the revert's view equals `earlier`'s | | **D-DSP-2a** | `log`: verified append-only batches of a `StoredImage` (pure, no I/O); torn saves refused; explicit prefix read | Shipped (#1482) | two-batch restore reads the same; four torn-save shapes refused; five disable runs red | -| **D-DSP-2** | Lance writer/reader crate for `StoredImage`: logical versions in verifiable append-only batches (one batch = one Lance commit), torn saves refused | In PR (2b: `lance-graph-dir-sim-lance`, one batch = one Lance append) | two saves restore to the same reads; missing dataset = empty log; stale writer writes nothing (create and append); torn batch refused at restore; out-of-order batch refused | +| **D-DSP-2** | Lance writer/reader crate for `StoredImage`: logical versions in verifiable append-only batches (one batch = one Lance commit), torn saves refused | Shipped (#1485; `lance-graph-dir-sim-lance`, one batch = one Lance append) | two saves restore to the same reads; missing dataset = empty log; stale writer writes nothing (create and append); torn batch refused at restore; out-of-order batch refused | | **D-DSP-4** | `StoredImage` + `RuleRegistry` (exact `(name, version)`) + `restore_stored` + read-only `History` (decision 2(a)) | Shipped (#1481) | unknown rule refused, never substituted; history still reads; disable runs red | | **D-DSP-3** | `ViewActors` (W-2) + `from_graph` groups (wishlist #19) in dir-sim | Shipped (#1473) | disable runs red | +| **D-DSP-3b** | `GroupRoles::from_policy`: grants read from one version of OGAR `GroupRolePolicy` (decision 4(b)); never-named = unread, all-revoked = read with no roles (5(a)) | In PR | policy test red without `from_policy`; dropping empty groups fails it | ## D-LBL — labels of a concept, pulled through the plug (2026-10-11) diff --git a/crates/lance-graph-dir-sim/Cargo.toml b/crates/lance-graph-dir-sim/Cargo.toml index bbf4ff2f5..2aa0247e9 100644 --- a/crates/lance-graph-dir-sim/Cargo.toml +++ b/crates/lance-graph-dir-sim/Cargo.toml @@ -38,13 +38,17 @@ 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 } +# Group-to-role grants are the authority's versioned policy +# (`ogar_rbac::GroupRolePolicy`); `GroupRoles::from_policy` reads one +# version of it. The directory supplies membership only. +ogar-rbac = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main", 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"] +rbac = ["dep:lance-graph-contract", "dep:ogar-rbac"] # 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 @@ -58,6 +62,16 @@ 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-rbac = { path = "../../../OGAR/crates/ogar-rbac" } +ogar-auth = { path = "../../../OGAR/crates/ogar-auth" } +ogar-vocab = { path = "../../../OGAR/crates/ogar-vocab" } + +# ogar-rbac names the contract and the RBAC kernel by git. Point them at +# this checkout so the crate builds against one contract: the `RoleId` the +# policy hands back is the one `ViewActors` answers with. +[patch."https://github.com/AdaWorldAPI/lance-graph"] +lance-graph-contract = { path = "../lance-graph-contract" } +lance-graph-rbac = { path = "../lance-graph-rbac" } [[test]] name = "view_actors" diff --git a/crates/lance-graph-dir-sim/src/actors.rs b/crates/lance-graph-dir-sim/src/actors.rs index f23c99656..ea8091504 100644 --- a/crates/lance-graph-dir-sim/src/actors.rs +++ b/crates/lance-graph-dir-sim/src/actors.rs @@ -17,6 +17,10 @@ //! data can never hand out a role the plug did not ask for; //! * any actor other than the principal holds nothing. //! +//! The grants are policy, not directory state: they live in the +//! authority's versioned [`GroupRolePolicy`](ogar_rbac::group_policy::GroupRolePolicy), +//! and [`GroupRoles::from_policy`] reads one version of it. +//! //! Roles are unscoped here; a consumer that binds a role to rows (a mailbox, //! a practice) supplies its own `memberships_of`. @@ -51,6 +55,18 @@ impl GroupRoles { self } + /// The grants as one version of the authority's group-role policy + /// states them. Every group the policy has named is read, with its + /// standing roles; a group whose grants were all revoked is read with + /// none. A group the policy never named stays unread, so it grants + /// nothing and [`ViewActors::unread_groups`] names it. + #[must_use] + pub fn from_policy(state: &ogar_rbac::group_policy::GroupRoleState) -> Self { + Self { + read: state.groups().collect(), + } + } + /// The roles of `group`, or `None` when its grants were never read. #[must_use] pub fn of(&self, group: &Guid128) -> Option<&[RoleId]> { diff --git a/crates/lance-graph-dir-sim/tests/view_actors.rs b/crates/lance-graph-dir-sim/tests/view_actors.rs index c24e8f7c2..ddcf79620 100644 --- a/crates/lance-graph-dir-sim/tests/view_actors.rs +++ b/crates/lance-graph-dir-sim/tests/view_actors.rs @@ -161,3 +161,66 @@ fn only_the_principal_holds_roles() { let a = ViewActors::for_principal(&view, &g(S1), &grants(), &b); assert!(a.roles().is_empty()); } + +// Grants come from the authority's versioned policy, not from the directory. +// A group the policy never named stays unread; a group whose grants were +// all revoked is read with no roles; an earlier policy version gives the +// roles that stood then. +#[test] +fn roles_come_from_one_version_of_the_group_role_policy() { + use ogar_rbac::group_policy::{GrantProvenance, GroupRolePolicy, PolicyChange}; + let by = |e: &str| GrantProvenance { + by: "admin".into(), + evidence: e.into(), + at_ms: 1, + }; + let mut policy = GroupRolePolicy::new(); + policy + .apply( + PolicyChange::Grant { + group: g(S1), + role: "reader", + }, + by("t1"), + ) + .unwrap(); + let granted = policy + .apply( + PolicyChange::Grant { + group: g(S3), + role: "editor", + }, + by("t2"), + ) + .unwrap(); + policy + .apply( + PolicyChange::Revoke { + group: g(S3), + role: "editor", + }, + by("t3"), + ) + .unwrap(); + + let mut st = VersionStore::new(); + let v = st.observe("lab", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + let b = binding(DECLARED); + + let now = GroupRoles::from_policy(&policy.as_of(policy.head()).unwrap()); + // u1: S1 grants `reader`; S2 (reached through S1) was never named by the + // policy, so it is unread, not empty. + let a = ViewActors::for_principal(&view, &g(U1), &now, &b); + assert_eq!(a.roles(), &["reader"]); + assert_eq!(a.unread_groups(), &[g(S2)]); + // u4: S3's only grant was revoked. Read with no roles, so not unread. + let a = ViewActors::for_principal(&view, &g(U4), &now, &b); + assert!(a.roles().is_empty()); + assert!(a.unread_groups().is_empty()); + + // At the version where S3 still granted `editor`, u4 holds it. + let then = GroupRoles::from_policy(&policy.as_of(granted).unwrap()); + let a = ViewActors::for_principal(&view, &g(U4), &then, &b); + assert_eq!(a.roles(), &["editor"]); +}