Skip to content

Commit 8b35dc0

Browse files
committed
feat(rbac): role carries a FieldMask projection — distinct views, not depth levels
RBAC is classid :: role :: membership, where the role IS a distinct projection of the class — not a graduated access level. PermissionSpec already had max_depth (a scalar level: Identity < … < Full), which only expresses more-vs-less of the same fields. It could not express that two roles see DISJOINT views of one class — the actual HIPAA mechanism (health-personnel sees the clinical histogram; invoice sees billing fields; research sees de-identified aggregate — and that the research cross-correlation would be unlawful in the invoice purpose). - contract FieldMask: add FULL (all positions), intersect, is_disjoint — the ops a projection + a distinctness check need. - rbac PermissionSpec: add `projection: FieldMask` (default FULL = no narrowing, depth governs), `with_projection(mask)` builder, `projects(n)` accessor. The projection is resolved against the class's ClassView field basis (lance-graph-ogar pulls OgarClassView for that basis). The projection SLOT is reusable here; the consumer (medcare-rs) hand-rolls the distinctness ENFORCEMENT — the three clinical roles' masks, and the invariant that the research projection is disjoint from the identifier fields. Test: two same-depth roles on one class with disjoint projections. lance-graph-rbac 15 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP
1 parent e1012ae commit 8b35dc0

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

crates/lance-graph-contract/src/class_view.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,26 @@ impl FieldMask {
123123
self.0 == 0
124124
}
125125

126+
/// The full mask — every addressable field position present. The
127+
/// "no projection constraint" default for an RBAC role that has not
128+
/// narrowed its view (lance-graph-rbac `PermissionSpec::projection`).
129+
pub const FULL: Self = Self(u64::MAX);
130+
131+
/// Bitwise intersection — the field positions present in BOTH masks.
132+
#[inline]
133+
pub const fn intersect(self, other: Self) -> Self {
134+
Self(self.0 & other.0)
135+
}
136+
137+
/// Do the two masks share NO field position? RBAC uses this to assert
138+
/// two roles project **distinct** views of the same class — e.g. a
139+
/// research projection must be disjoint from the identifier fields
140+
/// (`classid :: role :: membership`, where the role is the projection).
141+
#[inline]
142+
pub const fn is_disjoint(self, other: Self) -> bool {
143+
self.0 & other.0 == 0
144+
}
145+
126146
/// Inherit a parent class's presence into this mask — the **mask-inherits-as-
127147
/// delta** of the HHTL `subClassOf` walk (`wikidata-hhtl-load.md`). A child
128148
/// IS-A its parent, so its mask carries every field the parent declares

crates/lance-graph-rbac/src/permission.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
11
//! Permission specifications tied to the ontology layer.
22
3+
use lance_graph_contract::class_view::FieldMask;
34
use lance_graph_contract::property::PrefetchDepth;
45

56
/// What a role can do on a specific entity type.
7+
///
8+
/// # Depth is a level; projection is a view
9+
///
10+
/// [`max_depth`](Self::max_depth) is a *scalar level* (Identity < … < Full):
11+
/// how far down the prefetch ladder a role may read — "more or less of the
12+
/// same fields". It does NOT express **distinct** role-views of one class.
13+
///
14+
/// [`projection`](Self::projection) is the orthogonal axis: a [`FieldMask`]
15+
/// over the class's `ClassView` field basis naming exactly which fields the
16+
/// role may see. Two roles at the *same* depth can carry *disjoint*
17+
/// projections — the mechanism behind `classid :: role :: membership`, where
18+
/// the role IS the projection. A consumer (e.g. medcare-rs) gives
19+
/// `health-personnel`, `invoice`, and `research` three distinct projections
20+
/// of one clinical class and enforces that they never collapse (the research
21+
/// projection disjoint from the identifier fields, etc.). The distinctness
22+
/// enforcement is the consumer's; the projection slot is here.
623
#[derive(Clone, Debug)]
724
pub struct PermissionSpec {
825
/// Entity type this permission applies to (e.g. "Customer", "Invoice").
926
pub entity_type: &'static str,
1027
/// Maximum property prefetch depth this role can access.
1128
/// Identity = Required only, Full = everything including Free + episodic.
1229
pub max_depth: PrefetchDepth,
30+
/// The role's lawful **field projection** over this entity's `ClassView`
31+
/// field basis. [`FieldMask::FULL`] = no narrowing (depth governs); a
32+
/// narrowed mask is the per-role view that makes roles distinct rather
33+
/// than merely graduated.
34+
pub projection: FieldMask,
1335
/// Predicates this role can write. Empty = read-only for this entity.
1436
pub writable_predicates: &'static [&'static str],
1537
/// ActionSpec names this role can trigger. Empty = no actions.
@@ -22,6 +44,7 @@ impl PermissionSpec {
2244
Self {
2345
entity_type,
2446
max_depth: PrefetchDepth::Identity,
47+
projection: FieldMask::FULL,
2548
writable_predicates: &[],
2649
allowed_actions: &[],
2750
}
@@ -36,6 +59,7 @@ impl PermissionSpec {
3659
Self {
3760
entity_type,
3861
max_depth: PrefetchDepth::Full,
62+
projection: FieldMask::FULL,
3963
writable_predicates: writable,
4064
allowed_actions: actions,
4165
}
@@ -46,11 +70,27 @@ impl PermissionSpec {
4670
Self {
4771
entity_type,
4872
max_depth: depth,
73+
projection: FieldMask::FULL,
4974
writable_predicates: &[],
5075
allowed_actions: &[],
5176
}
5277
}
5378

79+
/// Narrow this permission to a specific field projection — the per-role
80+
/// view over the class's field basis. Builder; chains after any
81+
/// constructor (`read_at(..).with_projection(mask)`).
82+
pub const fn with_projection(mut self, projection: FieldMask) -> Self {
83+
self.projection = projection;
84+
self
85+
}
86+
87+
/// Is field position `n` (a bit in the class's `ClassView` field basis)
88+
/// within this role's projection? `false` = the role may not see it,
89+
/// regardless of depth.
90+
pub const fn projects(&self, n: u8) -> bool {
91+
self.projection.has(n)
92+
}
93+
5494
/// Check if this permission allows reading a predicate at the given depth.
5595
pub fn can_read_at(&self, depth: PrefetchDepth) -> bool {
5696
depth <= self.max_depth
@@ -76,10 +116,40 @@ mod tests {
76116
let p = PermissionSpec::read_only("Customer");
77117
assert_eq!(p.entity_type, "Customer");
78118
assert_eq!(p.max_depth, PrefetchDepth::Identity);
119+
// No narrowing by default — projection governs nothing until set.
120+
assert_eq!(p.projection, FieldMask::FULL);
79121
assert!(p.writable_predicates.is_empty());
80122
assert!(p.allowed_actions.is_empty());
81123
}
82124

125+
#[test]
126+
fn distinct_roles_carry_disjoint_projections_of_one_class() {
127+
// classid :: role :: membership — two roles, SAME entity, SAME
128+
// depth, but DISTINCT views. Mirrors the medcare shape: a billing
129+
// role sees the coded/amount fields; a research role sees only
130+
// de-identified aggregate fields. The point isn't more-vs-less
131+
// depth — it's that the two field projections are disjoint.
132+
//
133+
// (Field positions index the entity's ClassView field basis; here
134+
// 0,1 are identifier/clinical slots, 5,6 the de-identified slots.)
135+
let invoice = PermissionSpec::read_at("Diagnosis", PrefetchDepth::Detail)
136+
.with_projection(FieldMask::from_positions(&[0, 1]));
137+
let research = PermissionSpec::read_at("Diagnosis", PrefetchDepth::Detail)
138+
.with_projection(FieldMask::from_positions(&[5, 6]));
139+
140+
// Same level, distinct views.
141+
assert_eq!(invoice.max_depth, research.max_depth);
142+
assert!(
143+
invoice.projection.is_disjoint(research.projection),
144+
"the two roles must project distinct views of the class",
145+
);
146+
// The slot the invoice role sees, the research role does not.
147+
assert!(invoice.projects(0));
148+
assert!(!research.projects(0));
149+
assert!(research.projects(5));
150+
assert!(!invoice.projects(5));
151+
}
152+
83153
#[test]
84154
fn full_access_allows_writes() {
85155
let p = PermissionSpec::full("Invoice", &["status", "payment_date"], &["approve"]);

0 commit comments

Comments
 (0)