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
12 changes: 9 additions & 3 deletions .claude/board/LATEST_STATE.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
106 changes: 106 additions & 0 deletions .claude/knowledge/access-control-model.md
Original file line number Diff line number Diff line change
@@ -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.
177 changes: 175 additions & 2 deletions crates/lance-graph-contract/src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,63 @@ pub fn walk_rails<G, V, F>(
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<G, V, F>(
graph: &G,
class_view: &V,
registry: &ViewRegistry,
view_binding: impl Fn(ClassId) -> Option<ViewId>,
allowed: impl Fn(ClassId) -> WideFieldMask,
root: G::Key,
max_depth: usize,
visit: &mut F,
) where
G: RailGraph,
V: ClassView,
F: FnMut(FieldVisit<G::Key>),
{
let mut stack: HashSet<G::Key> = HashSet::new();
walk_node(
graph,
class_view,
registry,
&view_binding,
&|class: ClassId| Some(allowed(class)),
root,
0,
max_depth.min(MAX_WALK_DEPTH),
Expand All @@ -318,11 +375,12 @@ pub const MAX_WALK_DEPTH: usize = 64;
const MAX_POSITIONS: usize = 256;

#[allow(clippy::too_many_arguments)]
fn walk_node<G, V, B, F>(
fn walk_node<G, V, B, A, F>(
graph: &G,
class_view: &V,
registry: &ViewRegistry,
view_binding: &B,
allowed: &A,
key: G::Key,
depth: usize,
max_depth: usize,
Expand All @@ -332,6 +390,7 @@ fn walk_node<G, V, B, F>(
G: RailGraph,
V: ClassView,
B: Fn(ClassId) -> Option<ViewId>,
A: Fn(ClassId) -> Option<WideFieldMask>,
F: FnMut(FieldVisit<G::Key>),
{
if depth > max_depth {
Expand Down Expand Up @@ -366,7 +425,13 @@ fn walk_node<G, V, B, F>(
// 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.
Expand Down Expand Up @@ -398,6 +463,7 @@ fn walk_node<G, V, B, F>(
class_view,
registry,
view_binding,
allowed,
child,
depth + 1,
max_depth,
Expand Down Expand Up @@ -611,6 +677,113 @@ mod tests {
);
}

fn collect_authorized(
graph: &TestGraph,
cv: &TestClasses,
reg: &ViewRegistry,
binding: &HashMap<ClassId, ViewId>,
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<u32>| 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<ClassId, ViewId>, 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, &reg, &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, &reg, &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, &reg, &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, &reg, &binding, full, 10),
collect(&g, &cv, &reg, &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, &reg, &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();
Expand Down
Loading
Loading