Skip to main content

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}