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
5 changes: 5 additions & 0 deletions .claude/board/LATEST_STATE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2026-10-14 — per-classid field mask default on branch `ccr-0455e606-wmtsor` (unmerged)

- **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).

## 2026-10-14 — main after #1552 (merged)

- **On `main` (#1552, merged `98c03dd1`):** the Haiku harvest tier (six cards, supervision rules); policy only.
Expand Down
67 changes: 64 additions & 3 deletions crates/lance-graph-contract/src/rbac_plug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ pub struct RbacBinding {
classids: Vec<u16>,
grants: Vec<(RoleId, Vec<ClassGrant>)>,
field_masks: Vec<(RoleId, u16, WideFieldMask)>,
class_field_defaults: Vec<(u16, WideFieldMask)>,
}

impl RbacBinding {
Expand Down Expand Up @@ -166,9 +167,28 @@ impl RbacBinding {
classids,
grants,
field_masks,
class_field_defaults: Vec::new(),
}
}

/// Set, per plugged classid, the field mask a role gets when the authority
/// declared no mask of its own for it. Without this, such a role sees every
/// field ([`ClassRbac::field_mask`]'s open default); with it, fields left
/// out of the class default (sensitive properties) stay hidden unless a
/// role's own mask names them.
///
/// This is policy data keyed by classid, not a derivation: it replaces only
/// the open default and never changes a mask a role was given. Defaults on
/// a concept outside the plug are dropped, as in [`new`](RbacBinding::new).
#[must_use]
pub fn with_class_field_defaults(mut self, defaults: Vec<(u16, WideFieldMask)>) -> Self {
self.class_field_defaults = defaults
.into_iter()
.filter(|(c, _)| self.classids.contains(c))
.collect();
self
}

/// The consumer this binding was resolved for.
#[must_use]
pub fn consumer(&self) -> &str {
Expand Down Expand Up @@ -217,8 +237,9 @@ impl RbacBinding {
Ok(grants_permit(self.grants_for(role)?, class, op))
}

/// The field mask declared for `role` on `class`, or `None` when the
/// authority declared none (the class is then not column-restricted for
/// The field mask declared for `role` on `class`; failing that, the class
/// default set by [`with_class_field_defaults`](RbacBinding::with_class_field_defaults);
/// `None` when neither exists (the class is then not column-restricted for
/// that role, the same as [`ClassRbac::field_mask`]'s default).
///
/// # Errors
Expand All @@ -234,7 +255,12 @@ impl RbacBinding {
Ok(self
.field_masks
.iter()
.find_map(|(r, c, m)| (*r == role && *c == concept).then_some(m)))
.find_map(|(r, c, m)| (*r == role && *c == concept).then_some(m))
.or_else(|| {
self.class_field_defaults
.iter()
.find_map(|(c, m)| (*c == concept).then_some(m))
}))
}

/// Every declared `(role, grants)` row — the audit surface for tests and
Expand Down Expand Up @@ -514,6 +540,41 @@ mod tests {
assert_eq!(b.consumer(), "demo");
}

/// The class default is policy data keyed by classid: it applies only to
/// roles the authority declared no mask for, never widens a role's own
/// mask, and is dropped outside the plug like every other row.
#[test]
fn a_class_default_replaces_the_open_default_and_nothing_else() {
let sensitive_free = WideFieldMask::from_positions(&[0, 1, 2]);
let b = TinyAuthority
.bind(&PLUG)
.unwrap()
.with_class_field_defaults(vec![
(PATIENT, sensitive_free.clone()),
(LAB, WideFieldMask::EMPTY),
]);
// Without the default this role would see every field (FULL).
assert_eq!(
b.field_mask_for("physician", class(PATIENT)),
Ok(Some(&sensitive_free))
);
// A role's own mask wins over the class default.
assert_eq!(
b.field_mask_for("nurse", class(PATIENT)),
Ok(Some(&WideFieldMask::from_positions(&[0, 1])))
);
// A class without a default keeps the open default.
assert_eq!(b.field_mask_for("physician", class(DIAGNOSIS)), Ok(None));
// A default outside the plug is not carried.
assert_eq!(
b.field_mask_for("physician", class(LAB)),
Err(RbacDrift::NotPlugged(LAB))
);
let rbac = b.with_actors(Ward);
assert_eq!(rbac.field_mask("physician", class(PATIENT)), sensitive_free);
assert!(!rbac.field_mask("physician", class(PATIENT)).has(3));
}

#[test]
fn field_masks_are_per_role_and_plug_bounded() {
let b = TinyAuthority.bind(&PLUG).unwrap();
Expand Down
58 changes: 58 additions & 0 deletions crates/lance-graph-rbac/src/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,64 @@ mod plug_tests {
lab.decision
);
}

/// Sales reads patients with fields 0 and 1; admin reads them with no mask
/// of its own; compliance is granted the sensitive field 3.
struct Office;
impl ActorSource for Office {
fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId] {
match actor {
"mixed" => &["sales", "admin"],
"auditor" => &["sales", "compliance"],
_ => &[],
}
}
}

fn office_binding() -> RbacBinding {
let read_patient = || vec![ClassGrant::new(PATIENT, OpMask::READ)];
RbacBinding::new(
"demo",
vec![PATIENT],
vec![
("sales", read_patient()),
("admin", read_patient()),
("compliance", read_patient()),
],
vec![
("sales", PATIENT, WideFieldMask::from_positions(&[0, 1])),
("compliance", PATIENT, WideFieldMask::from_positions(&[3])),
],
)
}

// The union over roles must not let a role without a mask open the
// sensitive field once the class has a default; a role whose own mask
// names the field still sees it.
#[test]
fn a_class_default_keeps_sensitive_fields_out_of_the_union() {
let class = compose_classid(PATIENT, 0);
let open = office_binding();
let open_rbac = open.with_actors(Office);
assert!(
authorize_scoped(&open_rbac, "mixed", class, read())
.field_mask
.has(3),
"without a class default the admin role opens every field"
);

let closed = office_binding()
.with_class_field_defaults(vec![(PATIENT, WideFieldMask::from_positions(&[0, 1, 2]))]);
let rbac = closed.with_actors(Office);
let mixed = authorize_scoped(&rbac, "mixed", class, read());
assert_eq!(mixed.decision, AccessDecision::Allow);
assert_eq!(mixed.field_mask, WideFieldMask::from_positions(&[0, 1, 2]));
let auditor = authorize_scoped(&rbac, "auditor", class, read());
assert_eq!(
auditor.field_mask,
WideFieldMask::from_positions(&[0, 1, 3])
);
}
}

#[cfg(test)]
Expand Down
Loading