Skip to content

Commit 5f672f2

Browse files
authored
Merge pull request #600 from AdaWorldAPI/claude/medcare-bridge-lance-graph-wmx76z
rbac typed grant (§6) + OGAR DO-arm provider (Türsteher) + Rung↔elevation calibration
2 parents d8a59a4 + fe60a1b commit 5f672f2

5 files changed

Lines changed: 555 additions & 0 deletions

File tree

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

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,114 @@ pub trait ClassRbac {
8686
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool;
8787
}
8888

89+
// ─────────────────────────────────────────────────────────────────────────────
90+
// §6 — the typed `granted` value-tenant (first-class replacement for
91+
// `project_role.permissions: text`).
92+
// ─────────────────────────────────────────────────────────────────────────────
93+
94+
/// The verb bitmask of a class-grant — the §3 axis-1 "verb × class" gate, one
95+
/// `u8`, palette-native (#511 `SoaMemberSpec`: a role's grants are low-tens, one
96+
/// column). Shaped after Odoo `ir.model.access`'s `perm_{read,write,create,unlink}`.
97+
///
98+
/// This is the **coarse verb gate** (§5 stage 1). It answers "may this role
99+
/// *read / write / act on* this class at all", not the finer "at what depth /
100+
/// which predicate / which action name" — those are the field-projection (axis 4)
101+
/// and row-scope (axis 3) refinements that layer *above* a passed verb gate. So
102+
/// [`OpMask::permits`] maps [`Operation::Read`] → the `READ` bit regardless of
103+
/// depth; a depth/predicate/action-name check is a separate, finer stage.
104+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd, Ord, Hash)]
105+
pub struct OpMask(pub u8);
106+
107+
impl OpMask {
108+
/// May read the class (any depth).
109+
pub const READ: OpMask = OpMask(1 << 0);
110+
/// May write a predicate on the class.
111+
pub const WRITE: OpMask = OpMask(1 << 1);
112+
/// May create an instance (Odoo `perm_create`).
113+
pub const CREATE: OpMask = OpMask(1 << 2);
114+
/// May delete an instance (Odoo `perm_unlink`).
115+
pub const DELETE: OpMask = OpMask(1 << 3);
116+
/// May trigger a named action (the DO arm — `ActionDef` fire).
117+
pub const ACT: OpMask = OpMask(1 << 4);
118+
119+
/// The empty mask — grants nothing (restrictive default-deny).
120+
pub const NONE: OpMask = OpMask(0);
121+
122+
/// Union of two masks (grant composition; e.g. role-hierarchy fold).
123+
#[inline]
124+
#[must_use]
125+
pub const fn union(self, other: OpMask) -> OpMask {
126+
OpMask(self.0 | other.0)
127+
}
128+
129+
/// Whether `self` carries every bit of `bits`.
130+
#[inline]
131+
#[must_use]
132+
pub const fn contains(self, bits: OpMask) -> bool {
133+
self.0 & bits.0 == bits.0
134+
}
135+
136+
/// Whether this mask permits `op` — the verb gate. `Read` → `READ`,
137+
/// `Write` → `WRITE`, `Act` → `ACT` (depth / predicate / action-name are
138+
/// finer stages, not decided here).
139+
#[inline]
140+
#[must_use]
141+
pub fn permits(self, op: &Operation<'_>) -> bool {
142+
let bit = match op {
143+
Operation::Read { .. } => OpMask::READ,
144+
Operation::Write { .. } => OpMask::WRITE,
145+
Operation::Act { .. } => OpMask::ACT,
146+
};
147+
self.contains(bit)
148+
}
149+
}
150+
151+
/// One typed class-grant tuple — `(target_classid: u16, op_mask: u8)`. The
152+
/// first-class, palette-native replacement for the `project_role.permissions:
153+
/// text` blob (keystone §6 / I-K0 registry axiom: "decisions key on `classid`,
154+
/// not on text"). A role's `granted` value-tenant is a `&[ClassGrant]`.
155+
///
156+
/// `target_classid` is the **low `u16` codebook id** (the shared-concept half of
157+
/// a [`NodeGuid`](crate::NodeGuid)'s `classid`) — the RBAC + ontology identity,
158+
/// app-render-skin-independent (the hi `u16` chooses render, never grants).
159+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd, Ord, Hash)]
160+
pub struct ClassGrant {
161+
/// The class this grant targets (low-`u16` codebook id).
162+
pub target_classid: u16,
163+
/// The verbs this grant permits on that class.
164+
pub op_mask: OpMask,
165+
}
166+
167+
impl ClassGrant {
168+
/// Construct a grant.
169+
#[inline]
170+
#[must_use]
171+
pub const fn new(target_classid: u16, op_mask: OpMask) -> Self {
172+
Self {
173+
target_classid,
174+
op_mask,
175+
}
176+
}
177+
178+
/// Whether this grant permits `op` on `class`. Matches on the **low `u16`**
179+
/// of `class` (the codebook id), so a grant authored against the shared
180+
/// concept applies regardless of which app's render-skin (hi `u16`) the
181+
/// `ClassId` carries.
182+
#[inline]
183+
#[must_use]
184+
pub fn permits(&self, class: ClassId, op: &Operation<'_>) -> bool {
185+
self.target_classid == (class as u16) && self.op_mask.permits(op)
186+
}
187+
}
188+
189+
/// Does any grant in a role's `granted` set permit `op` on `class`? The slice
190+
/// form of the §5 stage-1 positive op-gate — the body a typed [`ClassRbac`] impl
191+
/// uses for `grant_permits` (restrictive default-deny: empty ⇒ `false`).
192+
#[must_use]
193+
pub fn grants_permit(granted: &[ClassGrant], class: ClassId, op: &Operation<'_>) -> bool {
194+
granted.iter().any(|g| g.permits(class, op))
195+
}
196+
89197
#[cfg(test)]
90198
mod tests {
91199
use super::*;
@@ -125,4 +233,84 @@ mod tests {
125233
));
126234
assert!(!rbac.grant_permits("reader", 0x0901, &Operation::Act { action: "x" }));
127235
}
236+
237+
// ── §6 typed `granted` value-tenant ──
238+
239+
const PATIENT: ClassId = 0x0000_0901;
240+
241+
#[test]
242+
fn opmask_permits_the_matching_verb_only() {
243+
let rw = OpMask::READ.union(OpMask::WRITE);
244+
assert!(rw.permits(&Operation::Read {
245+
depth: PrefetchDepth::Full
246+
}));
247+
assert!(rw.permits(&Operation::Write { predicate: "x" }));
248+
assert!(!rw.permits(&Operation::Act { action: "approve" }));
249+
// contains is bit-subset
250+
assert!(rw.contains(OpMask::READ));
251+
assert!(!rw.contains(OpMask::ACT));
252+
assert_eq!(OpMask::NONE, OpMask::default());
253+
}
254+
255+
#[test]
256+
fn class_grant_matches_on_low_u16_codebook_id() {
257+
let grant = ClassGrant::new(0x0901, OpMask::READ.union(OpMask::ACT));
258+
// Same concept, different app render-skin (hi u16) → still permitted:
259+
// the grant keys on the shared-concept low u16, never the render half.
260+
let app_a: ClassId = 0x0000_0901;
261+
let app_b: ClassId = 0xAB12_0901;
262+
let read = Operation::Read {
263+
depth: PrefetchDepth::Identity,
264+
};
265+
assert!(grant.permits(app_a, &read));
266+
assert!(grant.permits(app_b, &read));
267+
// Wrong concept → denied even with the verb.
268+
assert!(!grant.permits(0x0000_0902, &read));
269+
// Right concept, ungranted verb → denied.
270+
assert!(!grant.permits(app_a, &Operation::Write { predicate: "due" }));
271+
}
272+
273+
/// A typed [`ClassRbac`] impl whose `grant_permits` body IS [`grants_permit`]
274+
/// over a role's `granted` value-tenant — the §6 shape end-to-end, proving the
275+
/// typed tenant replaces `permissions: text` with contract-only types.
276+
struct TypedRoleGrants {
277+
// physician → {READ+ACT on PATIENT}; cashier → {READ on PATIENT}
278+
physician: [ClassGrant; 1],
279+
cashier: [ClassGrant; 1],
280+
}
281+
impl ClassRbac for TypedRoleGrants {
282+
fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId] {
283+
match actor {
284+
"dr-house" => &["physician"],
285+
"betty" => &["cashier"],
286+
_ => &[],
287+
}
288+
}
289+
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool {
290+
let granted: &[ClassGrant] = match role {
291+
"physician" => &self.physician,
292+
"cashier" => &self.cashier,
293+
_ => &[],
294+
};
295+
grants_permit(granted, class, op)
296+
}
297+
}
298+
299+
#[test]
300+
fn typed_granted_drives_grant_permits() {
301+
let rbac = TypedRoleGrants {
302+
physician: [ClassGrant::new(0x0901, OpMask::READ.union(OpMask::ACT))],
303+
cashier: [ClassGrant::new(0x0901, OpMask::READ)],
304+
};
305+
let act = Operation::Act { action: "approve" };
306+
// physician may act; cashier may not — restrictive default-deny.
307+
assert!(rbac.grant_permits("physician", PATIENT, &act));
308+
assert!(!rbac.grant_permits("cashier", PATIENT, &act));
309+
// both may read
310+
let read = Operation::Read {
311+
depth: PrefetchDepth::Identity,
312+
};
313+
assert!(rbac.grant_permits("physician", PATIENT, &read));
314+
assert!(rbac.grant_permits("cashier", PATIENT, &read));
315+
}
128316
}

0 commit comments

Comments
 (0)