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
3 changes: 2 additions & 1 deletion .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 15 additions & 1 deletion crates/lance-graph-dir-sim/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions crates/lance-graph-dir-sim/src/actors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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]> {
Expand Down
63 changes: 63 additions & 0 deletions crates/lance-graph-dir-sim/tests/view_actors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}
Loading