diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 9709f1810..43d1718a3 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -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. diff --git a/crates/lance-graph-contract/src/rbac_plug.rs b/crates/lance-graph-contract/src/rbac_plug.rs index f33b6b34b..823005340 100644 --- a/crates/lance-graph-contract/src/rbac_plug.rs +++ b/crates/lance-graph-contract/src/rbac_plug.rs @@ -132,6 +132,7 @@ pub struct RbacBinding { classids: Vec, grants: Vec<(RoleId, Vec)>, field_masks: Vec<(RoleId, u16, WideFieldMask)>, + class_field_defaults: Vec<(u16, WideFieldMask)>, } impl RbacBinding { @@ -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 { @@ -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 @@ -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 @@ -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(); diff --git a/crates/lance-graph-rbac/src/authorize.rs b/crates/lance-graph-rbac/src/authorize.rs index bc379acf2..4565531af 100644 --- a/crates/lance-graph-rbac/src/authorize.rs +++ b/crates/lance-graph-rbac/src/authorize.rs @@ -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)]