@@ -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) ]
90198mod 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