diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index ff8e72ca2..ba37d1969 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,11 +1,17 @@ -## 2026-10-14 — `commit` loses its admin bypass on branch `ccr-0455e606-wmtsor` (unmerged) +## 2026-10-14 — the rail walk applies the reader's field mask on branch `ccr-0455e606-wmtsor` (unmerged) + +- **Contract:** `selection::walk_rails_authorized(graph, class_view, registry, view_binding, allowed, root, max_depth, visit)`. Same walk as `walk_rails`, with `allowed(class)` added to the AND at every hop: `view.mask ∩ presence ∩ allowed(class)`. A hidden field is not emitted at any depth (each hop uses its own class's mask), and a hidden rail is not followed, so a linked object cannot be read through a link the reader may not see. `allowed` returns a mask, not an `Option`, so there is no permit-all fallback. `walk_rails` is unchanged. Closes H7 for every output built on the walk (OGAR `ogar-doc-ir` resolve uses it); the caller passes the `field_mask` of its `Decision` per class. Tests: four `selection::tests::authorized_walk_*`; three fail with the mask removed, the hidden-rail test fails with the mask applied to emission only. +- **rbac:** `ScopedDecision::visible_fields()` / `MembershipDecision::visible_fields()` return the field mask only when the decision allows, `EMPTY` otherwise. A refused decision keeps the open default in `field_mask`, so passing that field to the walk would show every field of a class the reader may not read. Tests `scoped_tests::a_refused_decision_shows_no_fields` (red with the check removed) and `an_allowed_decision_shows_its_field_mask`. +- **Open:** OGAR's `resolve_doc` does not pass a decision yet; the report seam (`lance-graph-report-ogar` grids) has no field positions to mask. + +## 2026-10-14 — `commit` loses its admin bypass (merged #1557) - **Contract:** `ActionInvocation::commit` no longer lets an `admin` role pass an action that requires another role; admin is an ordinary role, as it already was in `commit_via`. One authorization rule for both paths (decision 2, 2026-10-14). Test: `action::tests::commit_admin_is_a_role_not_a_bypass` (red before the change). No caller relied on the bypass (rs-graph-llm `graph-flow-action` tests use no admin role). -## 2026-10-14 — per-classid field mask default on branch `ccr-0455e606-wmtsor` (unmerged) +## 2026-10-14 — per-classid field mask default (merged #1556; OGAR side #358) - **Contract:** `RbacBinding::with_class_field_defaults(Vec<(u16, WideFieldMask)>)`. A role the authority declared no field mask for gets the class default instead of every field, so the union over a user's roles can no longer open a sensitive field through a mask-less role. Policy data keyed by classid; it never changes a declared mask and is dropped outside the plug. HubSPO-rs D-IAM-3 (POC). Tests: `rbac_plug::a_class_default_replaces_the_open_default_and_nothing_else`, `authorize::plug_tests::a_class_default_keeps_sensitive_fields_out_of_the_union`; both red with the lookup removed. -- **Open:** OGAR's authority does not set the default yet (`GrantSource` has no class-level mask); no output stage drops the masked columns (H7). +- **Open (closed since):** OGAR's authority now sets the default (`GrantSource::default_field_mask_of`, OGAR #358); the output stage is `walk_rails_authorized` (above). ## 2026-10-14 — main after #1552 (merged) diff --git a/.claude/knowledge/access-control-model.md b/.claude/knowledge/access-control-model.md new file mode 100644 index 000000000..cfff45464 --- /dev/null +++ b/.claude/knowledge/access-control-model.md @@ -0,0 +1,106 @@ +# Access-control model + +READ BY: anyone adding an RBAC consumer, a field mask, a row scope, an +output stage that shows records, or anything that decides who may do what. + +This document describes the model as it stands in code on 2026-10-14. Each +statement names the item that implements it. + +## The rule that comes before everything else + +**Access control is policy data, not reasoning.** A decision about who may read, +write or act is computed from roles, grants, scopes and masks, all keyed by +classid. It never consults or changes the reasoning substrate (CausalEdge64, +truth values, revision, inference). If a decision ever seems to need inference, +it goes to the global reasoning layer as a request. It is not hand-rolled as a +local heuristic inside access control. + +During the POC, policy overrides go through RBAC and the classid: a class-level +default, a role mask, a grant. They do not go through new code paths. + +DECISION (operator, 2026-10-14): "Keep separation of concerns. If something +needs reasoning it should use global reasoning and not handroll. Instead use +policy override during the POC via Rbac and classid." The reasoning layer is +changing quickly, and a local copy of it inside access control would drift +behind it. + +## Pipeline + +```text +identity ──► actor ──► roles / memberships ──► decision ──► output +(ogar-auth) (ActorSource) (ClassRbac) (authorize_*) (walk_rails_authorized) +``` + +1. **Identity.** `ogar-auth` authenticates a user (`AuthenticatedUser`). A + delegated token carries the acting party as an RFC 8693 `act` claim, which is + parsed at the token boundary. +2. **Actor.** An `ActorSource` turns the identity into held roles. + - `ogar_rbac::IdentityActors` reads them from the identity under a binding. + - `lance_graph_dir_sim::ViewActors` reads them from directory groups, + transitive members included, with group roles from OGAR's + `GroupRolePolicy` (`GroupRoles::from_policy`). +3. **Authority.** OGAR owns the policy. `GrantSource` provides `roles_of`, + `grants_of`, `scope_of`, `field_mask_of` and `default_field_mask_of`. + `OgarRbac::bind(&RbacPlug)` checks the consumer's plug against the classid + mirror and returns an `RbacBinding`. With actors attached, the binding + implements `ClassRbac`. A consumer never builds its own grant table. +4. **Decision.** `lance-graph-rbac` folds the roles: + - `authorize`: allow or deny. + - `authorize_scoped`: allow or deny, plus the row scope and field mask. + - `authorize_memberships`: the same, with scopes taken per membership. +5. **Output.** `selection::walk_rails_authorized` removes what the decision + hides before anything is emitted. + +## The four axes of a decision + +| axis | question | where | how it combines | +|---|---|---|---| +| verb × class | may this role read / write / act on this class at all? | `ClassGrant { class, op_mask }`, `grants_permit` | default deny; at least one granting role is required | +| row scope | which instances? | `ScopeSpec` (`row_scope`), `Membership` | `authorize_scoped`: intersection over granting roles. `authorize_memberships`: union over granting memberships, and one unscoped membership means no restriction | +| field projection | which fields? | `ClassRbac::field_mask` → `WideFieldMask` | union over granting roles: a field is visible if any granting role shows it | +| output | what leaves the system? | `walk_rails_authorized` + `visible_fields()` | `view.mask ∩ presence ∩ visible(class)` at every hop | + +Field masks are `WideFieldMask`, not `FieldMask`. A `u64` mask silently drops +positions 64 and above, which was a measured loss (a grant of `{1, 7, 92}` lost +92). + +## Rules that are easy to get wrong + +- **Admin is a role, not a bypass.** `ActionInvocation::commit` and + `commit_via` both require the action's `required_role`. An admin without that + role is refused (#1557). +- **A role without a mask must not open every field.** Field masks combine by + union, so one role that declares no mask would open every field for the user. + Two pieces prevent it: + - `RbacBinding::with_class_field_defaults` gives such a role the class + default instead of everything (#1556). + - OGAR's authority supplies the default through + `GrantSource::default_field_mask_of` (OGAR #358). + + A declared role mask is never replaced by the default. +- **A refused decision carries an open field mask.** On a non-`Allow` decision, + `ScopedDecision.field_mask` and `MembershipDecision.field_mask` hold the + open default and are documented as irrelevant. Output stages use + `visible_fields()`, which returns `EMPTY` unless the decision allows. +- **A hidden link hides what is behind it.** A rail is a field position. The + authorized walk does not follow a rail the reader may not see, so a linked + object is not reachable through it, even if the reader may read that class + directly. +- **No permit-all fallback in the output stage.** `walk_rails_authorized` takes + a mask per class, not an `Option`. A class the reader may not read yields + `EMPTY` and emits nothing. + +## Open + +- OGAR `ogar-doc-ir`'s `resolve_doc` calls `walk_rails`. It does not yet take + a decision. +- Report grids (`lance-graph-report-ogar`) have no per-field positions, so the + walk's mask does not apply to them. +- Accepted 2026-10-14, not yet implemented: + - relational algebra as the normal form of a decision, with Quack / + mask-risc as the executor (after a parity test); + - both setting keys with the mapping as an edge, and a sibling + `lance-graph-policy-sim` crate; + - a shared-mailbox role in Spear; + - a dir-sim owner gate that checks both uniqueness scopes, with the source as + a parameter. diff --git a/crates/lance-graph-contract/src/selection.rs b/crates/lance-graph-contract/src/selection.rs index 3eaf12205..ecdd94735 100644 --- a/crates/lance-graph-contract/src/selection.rs +++ b/crates/lance-graph-contract/src/selection.rs @@ -296,6 +296,63 @@ pub fn walk_rails( class_view, registry, &view_binding, + &|_: ClassId| None, + root, + 0, + max_depth.min(MAX_WALK_DEPTH), + &mut stack, + visit, + ); +} + +/// **The rail walk under an access decision.** Identical to [`walk_rails`], +/// with one more mask in the AND at every hop: +/// +/// ```text +/// effective = view.mask ∩ presence ∩ allowed(class) +/// ``` +/// +/// `allowed` answers, per class, which field positions the reader may see — +/// typically `visible_fields()` of the reader's authorization decision for +/// that class (`lance-graph-rbac`'s `ScopedDecision` / `MembershipDecision`), +/// which already folds role masks and the class default together. Use +/// `visible_fields()`, not the raw `field_mask` field: a refused decision +/// carries the open default there. The walk applies the mask; it decides +/// nothing. +/// +/// What this guarantees: +/// +/// - **A hidden field is never emitted**, at any depth: each hop applies the +/// mask of the hop's OWN class, so a reader who may see a work package but +/// not a user's e-mail gets the assignee's name and no e-mail. +/// - **A hidden rail is not followed**: a rail is a field position, so masking +/// it out also prunes everything reachable only through it. The reader +/// cannot learn a linked object's fields by walking a link they may not see. +/// - **Fail-closed by type**: `allowed` returns a mask, never an `Option`, so +/// there is no "unrestricted" answer to fall back to. A class the reader may +/// not read at all returns [`WideFieldMask::EMPTY`] and emits nothing. +#[allow(clippy::too_many_arguments)] +pub fn walk_rails_authorized( + graph: &G, + class_view: &V, + registry: &ViewRegistry, + view_binding: impl Fn(ClassId) -> Option, + allowed: impl Fn(ClassId) -> WideFieldMask, + root: G::Key, + max_depth: usize, + visit: &mut F, +) where + G: RailGraph, + V: ClassView, + F: FnMut(FieldVisit), +{ + let mut stack: HashSet = HashSet::new(); + walk_node( + graph, + class_view, + registry, + &view_binding, + &|class: ClassId| Some(allowed(class)), root, 0, max_depth.min(MAX_WALK_DEPTH), @@ -318,11 +375,12 @@ pub const MAX_WALK_DEPTH: usize = 64; const MAX_POSITIONS: usize = 256; #[allow(clippy::too_many_arguments)] -fn walk_node( +fn walk_node( graph: &G, class_view: &V, registry: &ViewRegistry, view_binding: &B, + allowed: &A, key: G::Key, depth: usize, max_depth: usize, @@ -332,6 +390,7 @@ fn walk_node( G: RailGraph, V: ClassView, B: Fn(ClassId) -> Option, + A: Fn(ClassId) -> Option, F: FnMut(FieldVisit), { if depth > max_depth { @@ -366,7 +425,13 @@ fn walk_node( // AND the view's selection mask with the node's presence: emit only fields // that are BOTH selected by the view AND populated on this instance (C2 // presence). - let effective = view.mask.intersect(&graph.present_mask(key)); + let mut effective = view.mask.intersect(&graph.present_mask(key)); + // The access decision for this hop's own class, when the caller gave one + // (`walk_rails_authorized`). Applied before the emit AND the rail loop, so + // a hidden rail is neither emitted nor followed. + if let Some(mask) = allowed(class) { + effective = effective.intersect(&mask); + } // Bound iteration to the class's declared field count (a class has exactly // `field_count` fields), capped at the u8 position ceiling. @@ -398,6 +463,7 @@ fn walk_node( class_view, registry, view_binding, + allowed, child, depth + 1, max_depth, @@ -611,6 +677,113 @@ mod tests { ); } + fn collect_authorized( + graph: &TestGraph, + cv: &TestClasses, + reg: &ViewRegistry, + binding: &HashMap, + allowed: impl Fn(ClassId) -> WideFieldMask, + root: u32, + ) -> Vec<(u32, ClassId, u8, usize)> { + let mut out = Vec::new(); + walk_rails_authorized( + graph, + cv, + reg, + |c| binding.get(&c).copied(), + allowed, + root, + 8, + &mut |v: FieldVisit| out.push((v.key, v.class, v.position, v.depth)), + ); + out + } + + /// A work package whose assignee rails to a user with name AND email + /// present, under a view that selects both user fields. + fn wp_with_assignee() -> (ViewRegistry, HashMap, TestGraph) { + let (mut reg, mut binding) = registry(); + binding.insert( + USER, + reg.register(NamedView::new( + USER, + WideFieldMask::from_positions(&[0, 1]), + DisplayTemplate::Card, + )), + ); + let mut g = TestGraph::new(); + g.node(10, WORK_PACKAGE, &[0, 1, 2]); + g.node(20, USER, &[0, 1]); + g.rail(10, 1, 20); + (reg, binding, g) + } + + #[test] + fn authorized_walk_hides_a_field_of_a_nested_class() { + let cv = TestClasses::new(); + let (reg, binding, g) = wp_with_assignee(); + // The reader may see every work-package field, but only the user's name. + let allowed = |c: ClassId| match c { + WORK_PACKAGE => WideFieldMask::from_positions(&[0, 1, 2]), + USER => WideFieldMask::from_positions(&[0]), + _ => WideFieldMask::EMPTY, + }; + let got = collect_authorized(&g, &cv, ®, &binding, allowed, 10); + assert_eq!( + got, + vec![ + (10, WORK_PACKAGE, 0, 0), + (10, WORK_PACKAGE, 1, 0), + (10, WORK_PACKAGE, 2, 0), + (20, USER, 0, 1), + ], + "the user's email is selected and present, but the reader may not see it" + ); + // The same graph walked without a decision shows the email: the + // fixture can reveal it, so its absence above comes from the mask. + let open = collect(&g, &cv, ®, &binding, 10, 8); + assert!(open.contains(&(20, USER, 1, 1))); + } + + #[test] + fn authorized_walk_does_not_follow_a_hidden_rail() { + let cv = TestClasses::new(); + let (reg, binding, g) = wp_with_assignee(); + // The assignee rail (position 1) is hidden; the user class is fully + // readable, but nothing may reach it through this work package. + let allowed = |c: ClassId| match c { + WORK_PACKAGE => WideFieldMask::from_positions(&[0, 2]), + USER => WideFieldMask::from_positions(&[0, 1]), + _ => WideFieldMask::EMPTY, + }; + let got = collect_authorized(&g, &cv, ®, &binding, allowed, 10); + assert_eq!( + got, + vec![(10, WORK_PACKAGE, 0, 0), (10, WORK_PACKAGE, 2, 0)], + "a hidden rail is neither emitted nor followed" + ); + } + + #[test] + fn authorized_walk_with_a_full_decision_equals_the_plain_walk() { + let cv = TestClasses::new(); + let (reg, binding, g) = wp_with_assignee(); + let full = |_: ClassId| WideFieldMask::from_positions(&[0, 1, 2]); + assert_eq!( + collect_authorized(&g, &cv, ®, &binding, full, 10), + collect(&g, &cv, ®, &binding, 10, 8), + "a decision that hides nothing changes nothing" + ); + } + + #[test] + fn authorized_walk_emits_nothing_for_an_unreadable_root() { + let cv = TestClasses::new(); + let (reg, binding, g) = wp_with_assignee(); + let got = collect_authorized(&g, &cv, ®, &binding, |_| WideFieldMask::EMPTY, 10); + assert!(got.is_empty(), "no readable field, no rail, no output"); + } + #[test] fn presence_and_view_are_anded() { let cv = TestClasses::new(); diff --git a/crates/lance-graph-rbac/src/authorize.rs b/crates/lance-graph-rbac/src/authorize.rs index 4565531af..5b00792de 100644 --- a/crates/lance-graph-rbac/src/authorize.rs +++ b/crates/lance-graph-rbac/src/authorize.rs @@ -181,6 +181,24 @@ pub struct ScopedDecision { pub field_mask: WideFieldMask, } +impl ScopedDecision { + /// The field positions the actor may see: [`field_mask`](Self::field_mask) + /// when access is allowed, [`WideFieldMask::EMPTY`] otherwise. + /// + /// Use this, not `field_mask`, when the mask feeds an output stage such as + /// `selection::walk_rails_authorized`: a refused decision carries the open + /// default in `field_mask`, which would show every field of a class the + /// actor may not read. + #[must_use] + pub fn visible_fields(&self) -> WideFieldMask { + if matches!(self.decision, AccessDecision::Allow) { + self.field_mask.clone() + } else { + WideFieldMask::EMPTY + } + } +} + /// §5 two-stage authorize: stage-1 is the unchanged positive∧op-gate /// ([`authorize`]); stage-2 folds the **granting subset** (the roles that /// actually permit `op` — the SAME predicate stage-1 uses, NOT `roles_reaching`) @@ -249,6 +267,20 @@ pub struct MembershipDecision { pub field_mask: WideFieldMask, } +impl MembershipDecision { + /// The field positions the actor may see: [`field_mask`](Self::field_mask) + /// when access is allowed, [`WideFieldMask::EMPTY`] otherwise. See + /// [`ScopedDecision::visible_fields`] for why output stages use this. + #[must_use] + pub fn visible_fields(&self) -> WideFieldMask { + if matches!(self.decision, AccessDecision::Allow) { + self.field_mask.clone() + } else { + WideFieldMask::EMPTY + } + } +} + /// Authorize through [`ClassRbac::memberships`], the permissive counterpart of /// [`authorize_scoped`]. /// @@ -698,6 +730,26 @@ mod scoped_tests { assert_eq!(d.scope, None); assert_eq!(d.field_mask, WideFieldMask::from(FieldMask::FULL)); } + #[test] + fn a_refused_decision_shows_no_fields() { + let d = authorize_scoped(&NoRoles, "ghost", CLS, Operation::Act { action: "x" }); + assert_eq!(d.field_mask, WideFieldMask::from(FieldMask::FULL)); + assert_eq!(d.visible_fields(), WideFieldMask::EMPTY); + let m = authorize_memberships(&NoRoles, "ghost", CLS, Operation::Act { action: "x" }); + assert!(!matches!(m.decision, AccessDecision::Allow)); + assert_eq!(m.field_mask, WideFieldMask::from(FieldMask::FULL)); + assert_eq!(m.visible_fields(), WideFieldMask::EMPTY); + } + #[test] + fn an_allowed_decision_shows_its_field_mask() { + let d = authorize_scoped(&DualGrantRbac, "u", CLS, Operation::Act { action: "x" }); + assert!(matches!(d.decision, AccessDecision::Allow)); + assert_eq!(d.visible_fields(), d.field_mask); + assert!(d.visible_fields().has(0)); + let m = authorize_memberships(&DualGrantRbac, "u", CLS, Operation::Act { action: "x" }); + assert!(matches!(m.decision, AccessDecision::Allow)); + assert_eq!(m.visible_fields(), m.field_mask); + } // ── T5 (F5) wide-projection regression ──────────────────────────────── // // MEASURED DEFECT this widening fixes: with `ClassRbac::field_mask`