miden_standards/account/access/rbac.rs
1use alloc::collections::BTreeSet;
2use alloc::vec;
3
4use miden_protocol::account::component::{
5 AccountComponentCode,
6 AccountComponentMetadata,
7 SchemaType,
8 StorageSchema,
9 StorageSlotSchema,
10};
11use miden_protocol::account::{
12 AccountComponent,
13 AccountComponentName,
14 AccountId,
15 RoleSymbol,
16 StorageMap,
17 StorageMapKey,
18 StorageSlot,
19 StorageSlotName,
20};
21use miden_protocol::utils::sync::LazyLock;
22use miden_protocol::{Felt, Word};
23
24use crate::account::account_component_code;
25
26account_component_code!(RBAC_CODE, "miden-standards-access-rbac.masp");
27
28static ROLE_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
29 StorageSlotName::new("miden::standards::access::rbac::role_config")
30 .expect("storage slot name should be valid")
31});
32static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
33 StorageSlotName::new("miden::standards::access::rbac::role_membership")
34 .expect("storage slot name should be valid")
35});
36
37/// Role-based access control (RBAC) for account components.
38///
39/// Instead of having one account holding every privilege, privileges are split into named
40/// roles (for example `MINTER`, `BURNER`, `PAUSER`), and each procedure is guarded against
41/// the caller's role membership. It allows role assignment with domain isolation to minimize
42/// the scope of damage from a compromised role.
43///
44/// ## Security considerations
45///
46/// Access control is based on the note sender (the account ID that created the note), which
47/// authenticates *which account* created a note but not the *code* that executed when it was
48/// created. It is meaningful only when every account registered as a role member enforces
49/// strong authentication. Registering a permissionless account (for example one using `no_auth`)
50/// as a role member provides no access restriction: anyone can make such an account emit a
51/// note with an arbitrary script root and that account's ID as sender, defeating the sender check.
52///
53/// ## Administration model
54///
55/// Role administration is fully role-based. Every role has an *effective admin role*:
56/// its configured delegated admin when set, otherwise the built-in
57/// [`ADMIN`][Self::ADMIN_ROLE] role. Only members of a role's effective admin role may grant,
58/// revoke, or re-point (`set_role_admin`) that role.
59///
60/// The component is seeded at construction with one or more members of the `ADMIN` role (see
61/// [`new`][Self::new] / [`with_admins`][Self::with_admins]); this bootstraps administration.
62/// The `ADMIN` role administers itself, so `ADMIN` membership can be granted, revoked, and
63/// renounced through the standard API.
64///
65/// ## Role hierarchy and exclusive delegation
66///
67/// Every role may have its admin delegated to another role via `set_role_admin`. Accounts
68/// holding a role's admin role are authorized to grant and revoke that role. For example,
69/// accounts holding `MINTER_ADMIN` can manage the `MINTER` role but have no authority over
70/// `BURNER` or `PAUSER`.
71///
72/// Delegation is *exclusive*: once a role's admin is delegated to another role, the `ADMIN`
73/// role loses all authority over it (grant, revoke, and further `set_role_admin` are then
74/// gated on the delegated admin). This lets a sensitive role — say a token issuer — be placed
75/// exclusively under a dedicated admin role and kept out of reach of the general
76/// administrator. To hand authority back, the current delegated admin re-points the role
77/// (passing `0` reverts it to the `ADMIN` role).
78///
79/// This supports a fully decentralized configuration: seed `ADMIN`, populate each role's
80/// dedicated admin role, delegate, and finally revoke or renounce the bootstrap `ADMIN`
81/// members so no single key retains authority over the whole graph.
82///
83/// The delegated admin of a role can itself be any role, including one that it admins.
84/// Circular relationships are possible but should be designed with care, since each role
85/// can then revoke the other. Note that revoking the last member of a role's effective admin
86/// (including the last `ADMIN` member for undelegated roles) leaves that role unmanageable
87/// until a member of its effective admin role is restored — treat `ADMIN` renouncement with
88/// the same caution as ownership renouncement.
89///
90/// ## Role semantics
91///
92/// A role is considered to exist when it has at least one member. Granting the first
93/// member creates the role; revoking the last member removes it. As a consequence,
94/// `set_role_admin(A, B)` stores the admin relationship in storage but does not make role
95/// `A` exist until a member is granted. Once the last member of `A` is revoked,
96/// `get_role_member_count(A)` returns `0`, though the admin configuration is retained and
97/// will apply the next time a member is granted.
98///
99/// ## Membership lookup
100///
101/// `has_role` procedure is the primary guard used by procedures that assert the caller's
102/// role membership. `get_role_member_count` returns the number of accounts holding a role.
103///
104/// ## Role symbol format
105///
106/// A [`RoleSymbol`] encodes up to 12 uppercase ASCII characters with underscores into a
107/// single field element using the same packing as the token symbol type. Examples:
108/// `MINTER`, `MINTER_ADMIN`, `PAUSER`. The zero field element is reserved and cannot be
109/// used as a role symbol; attempting to do so panics with `ERR_ROLE_SYMBOL_ZERO`.
110///
111/// ## Usage
112///
113/// Guarding a procedure in MASM so that only members of `MINTER` can call it:
114///
115/// ```text
116/// pub proc mint
117/// push.MINTER_ROLE_SYMBOL
118/// exec.::miden::standards::access::rbac::assert_sender_has_role
119/// # add mint logic
120/// end
121/// ```
122///
123/// [`RoleSymbol`]: miden_protocol::account::RoleSymbol
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RoleBasedAccessControl {
126 /// Accounts seeded as members of the built-in [`ADMIN`][Self::ADMIN_ROLE] role at
127 /// construction. Bootstraps role administration; may be empty for an account that seeds
128 /// `ADMIN` membership by other means, but such a component starts with no administrator.
129 initial_admins: BTreeSet<AccountId>,
130}
131
132impl RoleBasedAccessControl {
133 pub const NAME: &'static str = "miden::standards::components::access::rbac";
134
135 /// The built-in default admin role symbol. A role whose delegated admin is unset is
136 /// administered by members of this role.
137 ///
138 /// Keep in sync with the `ADMIN_ROLE` constant in `asm/standards/access/rbac.masm`.
139 pub const ADMIN_ROLE: &'static str = "ADMIN";
140
141 /// Returns the built-in default admin [`RoleSymbol`].
142 pub fn admin_role() -> RoleSymbol {
143 RoleSymbol::new(Self::ADMIN_ROLE).expect("ADMIN is a valid role symbol")
144 }
145
146 /// Returns the canonical [`AccountComponentName`] of this component.
147 pub const fn name() -> AccountComponentName {
148 AccountComponentName::from_static_str(Self::NAME)
149 }
150
151 /// Returns the [`AccountComponentCode`] of this component.
152 pub fn code() -> &'static AccountComponentCode {
153 &RBAC_CODE
154 }
155
156 /// Returns an RBAC component whose `ADMIN` role is seeded with the given `initial_admin`.
157 ///
158 /// The initial admin bootstraps role administration: it can grant every role (including
159 /// `ADMIN`), configure delegated admins, and later hand off or renounce its own `ADMIN`
160 /// membership. Additional roles are populated at runtime via the `grant_role`,
161 /// `set_role_admin`, etc. procedures exposed by the component.
162 pub fn new(initial_admin: AccountId) -> Self {
163 Self {
164 initial_admins: BTreeSet::from([initial_admin]),
165 }
166 }
167
168 /// Returns an RBAC component whose `ADMIN` role is seeded with the given `initial_admins`.
169 ///
170 /// Passing an empty set produces a component with no initial administrator, which cannot
171 /// manage any role until `ADMIN` membership is established by other means; prefer
172 /// [`new`][Self::new] unless that is intended.
173 pub fn with_admins(initial_admins: BTreeSet<AccountId>) -> Self {
174 Self { initial_admins }
175 }
176
177 /// Returns the storage slot name for the per-role config map.
178 pub fn role_config_slot() -> &'static StorageSlotName {
179 &ROLE_CONFIG_SLOT_NAME
180 }
181
182 /// Returns the storage slot name for the per-role membership map.
183 pub fn role_membership_slot() -> &'static StorageSlotName {
184 &ROLE_MEMBERSHIP_SLOT_NAME
185 }
186
187 /// Returns the schema entry for the per-role config map.
188 pub fn role_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
189 (
190 Self::role_config_slot().clone(),
191 StorageSlotSchema::map(
192 "Per-role RBAC configuration (member count and delegated admin role)",
193 SchemaType::role_symbol(),
194 SchemaType::native_word(),
195 ),
196 )
197 }
198
199 /// Returns the schema entry for the per-role membership map.
200 pub fn role_membership_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
201 (
202 Self::role_membership_slot().clone(),
203 StorageSlotSchema::map(
204 "Role membership flag indexed by role symbol and account ID",
205 SchemaType::native_word(),
206 SchemaType::native_word(),
207 ),
208 )
209 }
210
211 /// Returns the [`AccountComponentMetadata`] describing this component.
212 pub fn component_metadata() -> AccountComponentMetadata {
213 let storage_schema = StorageSchema::new(vec![
214 Self::role_config_slot_schema(),
215 Self::role_membership_slot_schema(),
216 ])
217 .expect("storage schema should be valid");
218
219 AccountComponentMetadata::new(Self::NAME)
220 .with_description("Role-based access control component")
221 .with_storage_schema(storage_schema)
222 }
223}
224
225impl From<RoleBasedAccessControl> for AccountComponent {
226 fn from(rbac: RoleBasedAccessControl) -> Self {
227 let admin_symbol: Felt = RoleBasedAccessControl::admin_role().as_element();
228 let admins = rbac.initial_admins;
229
230 // Seed ADMIN membership: [0, admin_role, suffix, prefix] -> [1, 0, 0, 0].
231 let membership_entries = admins.iter().map(|admin| {
232 (
233 StorageMapKey::new(Word::from([
234 Felt::ZERO,
235 admin_symbol,
236 admin.suffix(),
237 admin.prefix().as_felt(),
238 ])),
239 Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
240 )
241 });
242 let role_membership_map = StorageMap::with_entries(membership_entries)
243 .expect("seeded role membership map should be valid");
244
245 // Seed the ADMIN role config with its member count. The delegated admin is left unset
246 // (0) so ADMIN administers itself. When there are no admins, the config stays empty.
247 let role_config_map = if admins.is_empty() {
248 StorageMap::default()
249 } else {
250 let member_count =
251 u32::try_from(admins.len()).expect("number of initial admins should fit in u32");
252 StorageMap::with_entries(vec![(
253 StorageMapKey::new(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, admin_symbol])),
254 Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]),
255 )])
256 .expect("seeded role config map should be valid")
257 };
258
259 let role_config_slot = StorageSlot::with_map(
260 RoleBasedAccessControl::role_config_slot().clone(),
261 role_config_map,
262 );
263 let role_membership_slot = StorageSlot::with_map(
264 RoleBasedAccessControl::role_membership_slot().clone(),
265 role_membership_map,
266 );
267
268 AccountComponent::new(
269 RoleBasedAccessControl::code().clone(),
270 vec![role_config_slot, role_membership_slot],
271 RoleBasedAccessControl::component_metadata(),
272 )
273 .expect("RBAC component should satisfy the requirements of a valid account component")
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use miden_protocol::account::{AccountType, StorageSlotContent};
280
281 use super::*;
282
283 fn test_admin(seed: u8) -> AccountId {
284 AccountId::builder()
285 .account_type(AccountType::Private)
286 .build_with_seed([seed; 32])
287 }
288
289 /// Returns the map content of the component's storage slot with the given name.
290 fn find_map<'a>(
291 component: &'a AccountComponent,
292 slot_name: &StorageSlotName,
293 ) -> &'a StorageMap {
294 let slot = component
295 .storage_slots()
296 .iter()
297 .find(|slot| slot.name() == slot_name)
298 .expect("component should register the slot");
299 match slot.content() {
300 StorageSlotContent::Map(map) => map,
301 _ => panic!("slot {slot_name} should be a map"),
302 }
303 }
304
305 #[test]
306 fn admin_role_encoding_matches_masm_constant() {
307 // Must stay in sync with `const ADMIN_ROLE` in asm/standards/access/rbac.masm.
308 const MASM_ADMIN_ROLE: u64 = 1836707;
309 assert_eq!(
310 RoleBasedAccessControl::admin_role().as_element().as_canonical_u64(),
311 MASM_ADMIN_ROLE,
312 );
313 }
314
315 #[test]
316 fn with_admins_seeds_every_admin_and_the_member_count() {
317 // `initial_admins` is a `BTreeSet`, so duplicate account IDs collapse before this point
318 // and the member count always matches the number of membership entries.
319 let admins = [test_admin(1), test_admin(2), test_admin(3)];
320 let component: AccountComponent =
321 RoleBasedAccessControl::with_admins(admins.iter().copied().collect()).into();
322
323 let admin_symbol = RoleBasedAccessControl::admin_role().as_element();
324
325 let membership = find_map(&component, RoleBasedAccessControl::role_membership_slot());
326 assert_eq!(membership.num_entries(), admins.len());
327 for admin in admins {
328 let key = StorageMapKey::new(Word::from([
329 Felt::ZERO,
330 admin_symbol,
331 admin.suffix(),
332 admin.prefix().as_felt(),
333 ]));
334 assert_eq!(
335 membership.get(&key),
336 Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO])
337 );
338 }
339
340 let config = find_map(&component, RoleBasedAccessControl::role_config_slot());
341 let config_key =
342 StorageMapKey::new(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, admin_symbol]));
343 let member_count = u32::try_from(admins.len()).unwrap();
344 assert_eq!(
345 config.get(&config_key),
346 Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]),
347 );
348 }
349
350 #[test]
351 fn with_admins_empty_seeds_no_admin() {
352 let component: AccountComponent =
353 RoleBasedAccessControl::with_admins(BTreeSet::new()).into();
354
355 // No membership entries and an empty config: the component starts with no administrator.
356 let membership = find_map(&component, RoleBasedAccessControl::role_membership_slot());
357 assert_eq!(membership.num_entries(), 0);
358 let config = find_map(&component, RoleBasedAccessControl::role_config_slot());
359 assert_eq!(config.num_entries(), 0);
360 }
361}