Skip to main content

miden_standards/note/config/
rbac_config.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::{AccountId, RoleSymbol};
4use miden_protocol::assembly::Path;
5use miden_protocol::crypto::rand::FeltRng;
6use miden_protocol::errors::NoteError;
7use miden_protocol::note::{
8    Note,
9    NoteAssets,
10    NoteAttachment,
11    NoteAttachments,
12    NoteRecipient,
13    NoteScript,
14    NoteScriptRoot,
15    NoteStorage,
16    NoteTag,
17    NoteType,
18    PartialNoteMetadata,
19};
20use miden_protocol::utils::sync::LazyLock;
21use miden_protocol::{Felt, Word};
22
23use crate::StandardsLib;
24use crate::note::costs::{NoteConsumptionCost, RBAC_CONFIG_CONSUMPTION_CYCLES};
25use crate::note::{AccountTargetNetworkNote, NetworkAccountTarget, NumStorageItems};
26
27// NOTE SCRIPT
28// ================================================================================================
29
30/// Path to the RBAC_CONFIG note script procedure in the standards library.
31const RBAC_CONFIG_SCRIPT_PATH: &str = "::miden::standards::notes::rbac_config::main";
32
33// Initialize the RBAC_CONFIG note script only once.
34static RBAC_CONFIG_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
35    let standards_lib = StandardsLib::default();
36    let path = Path::new(RBAC_CONFIG_SCRIPT_PATH);
37    NoteScript::from_package_reference(standards_lib.as_ref(), path)
38        .expect("Standards library contains RBAC_CONFIG note script procedure")
39});
40
41// RBAC CONFIG
42// ================================================================================================
43
44/// A management action of the
45/// [`RoleBasedAccessControl`](crate::account::access::RoleBasedAccessControl) component that an
46/// [`RbacConfigNote`] triggers on the account that consumes it.
47///
48/// The action, together with its arguments, is encoded into the note's storage (see
49/// [`NoteStorage`] conversion below). Because the storage is fixed at note creation and bound into
50/// the note commitment, the authorized party is the note sender: the consuming account's `rbac`
51/// procedures authorize against `active_note::get_sender`.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum RbacConfig {
54    /// Grant `role` to `account`. Only a member of the role's effective admin role is authorized.
55    GrantRole { role: RoleSymbol, account: AccountId },
56    /// Revoke `role` from `account`. Only a member of the role's effective admin role is
57    /// authorized.
58    RevokeRole { role: RoleSymbol, account: AccountId },
59    /// Set the admin role of `role` to `admin_role`. A value of `None` reverts `role` to
60    /// management by the default `ADMIN` role. Only a member of the role's current effective admin
61    /// role is authorized.
62    SetRoleAdmin {
63        role: RoleSymbol,
64        admin_role: Option<RoleSymbol>,
65    },
66    /// Renounce `role` held by the note sender.
67    RenounceRole { role: RoleSymbol },
68}
69
70impl RbacConfig {
71    // VARIANTS
72    // --------------------------------------------------------------------------------------------
73
74    // Config note variants stored in the first storage item. Keep in sync with `rbac_config.masm`.
75    const VARIANT_GRANT_ROLE: u8 = 0;
76    const VARIANT_REVOKE_ROLE: u8 = 1;
77    const VARIANT_SET_ROLE_ADMIN: u8 = 2;
78    const VARIANT_RENOUNCE_ROLE: u8 = 3;
79
80    /// Returns the note storage values encoding this action, laid out as `[variant, ..args]`.
81    fn to_storage_values(&self) -> Vec<Felt> {
82        match self {
83            RbacConfig::GrantRole { role, account } => {
84                vec![
85                    Felt::from(Self::VARIANT_GRANT_ROLE),
86                    role.as_element(),
87                    account.suffix(),
88                    account.prefix().as_felt(),
89                ]
90            },
91            RbacConfig::RevokeRole { role, account } => {
92                vec![
93                    Felt::from(Self::VARIANT_REVOKE_ROLE),
94                    role.as_element(),
95                    account.suffix(),
96                    account.prefix().as_felt(),
97                ]
98            },
99            RbacConfig::SetRoleAdmin { role, admin_role } => {
100                // A missing admin role is encoded as 0, the value `rbac::set_role_admin` treats as
101                // "revert to the default ADMIN role".
102                let admin_role = admin_role.as_ref().map_or(Felt::ZERO, RoleSymbol::as_element);
103                vec![Felt::from(Self::VARIANT_SET_ROLE_ADMIN), role.as_element(), admin_role]
104            },
105            RbacConfig::RenounceRole { role } => {
106                vec![Felt::from(Self::VARIANT_RENOUNCE_ROLE), role.as_element()]
107            },
108        }
109    }
110}
111
112impl From<RbacConfig> for NoteStorage {
113    fn from(config: RbacConfig) -> Self {
114        NoteStorage::new(config.to_storage_values())
115            .expect("number of storage items should not exceed max storage items")
116    }
117}
118
119// RBAC CONFIG NOTE
120// ================================================================================================
121
122/// An RbacConfig note: triggers a
123/// [`RoleBasedAccessControl`](crate::account::access::RoleBasedAccessControl) management action on
124/// the account that consumes it.
125///
126/// A single note script dispatches on the note variant in its storage to one of the component's
127/// management procedures (`grant_role`, `revoke_role`, `set_role_admin`, `renounce_role`). All
128/// authorization is enforced by those procedures against the note sender, so the note carries no
129/// assets and its authorization is bound to `sender` at creation time.
130///
131/// The note is always public and tagged for `account` — the account carrying the
132/// `RoleBasedAccessControl` component whose role graph is being managed. The `sender` is the
133/// account authorized for the selected action: a member of the role's effective admin role for
134/// `GrantRole` / `RevokeRole` / `SetRoleAdmin`, or the role holder itself for `RenounceRole`.
135///
136/// The note is bound to the target `account` by a [`NetworkAccountTarget`] attachment: the script
137/// asserts that the consuming account matches that target before dispatching, so the note cannot be
138/// consumed by a third-party account that merely accepts its sender. The binding also
139/// makes the note a valid [`AccountTargetNetworkNote`], routing it to `account` for network
140/// execution.
141///
142/// The note must be public: the script rejects a non-public note. See
143/// [the module docs](crate::note::config#note-type) for the layers that enforce it.
144///
145/// Construct one with the [builder](RbacConfigNote::builder); convert it into a protocol [`Note`]
146/// infallibly via `Note::from`.
147///
148/// ## Security considerations
149///
150/// A created note is an unordered, unexpiring, uncancellable instruction — treat it as a
151/// standing capability and do not create role-management notes ahead of need. Consumption order
152/// is chosen by whoever consumes the note, so when rotating a role, wait for the successor's
153/// grant to commit before issuing any revoke or renounce (a note that fails because its sender
154/// currently lacks the role stays pending and revives if the sender regains it).
155#[derive(Debug, Clone)]
156pub struct RbacConfigNote {
157    sender: AccountId,
158    target: AccountId,
159    config: RbacConfig,
160    serial_number: Word,
161    attachments: NoteAttachments,
162}
163
164#[bon::bon]
165impl RbacConfigNote {
166    /// Builds a new [`RbacConfigNote`] that applies `config` to `account`.
167    ///
168    /// The note is bound to `account` by a [`NetworkAccountTarget`] attachment that the builder
169    /// appends unless the caller already supplied one for `account`.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if:
174    /// - `account` is not a public account (the note is bound to it via a `NetworkAccountTarget`,
175    ///   which requires a public target).
176    /// - the attachments carry a `NetworkAccountTarget` for an account other than `account`.
177    /// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
178    ///   attachment occupies one of the available slots when the caller does not supply it.
179    #[builder]
180    pub fn new(
181        #[builder(field)] mut attachments: Vec<NoteAttachment>,
182        sender: AccountId,
183        target: AccountId,
184        config: RbacConfig,
185        serial_number: Word,
186    ) -> Result<Self, NoteError> {
187        // The note script asserts that the consuming account matches this target before
188        // dispatching.
189        NetworkAccountTarget::ensure_presence(&mut attachments, target).map_err(|err| {
190            NoteError::other_with_source(
191                "failed to bind the RbacConfig note to its target account",
192                err,
193            )
194        })?;
195        let attachments = NoteAttachments::new(attachments)?;
196
197        Ok(Self {
198            sender,
199            target,
200            config,
201            serial_number,
202            attachments,
203        })
204    }
205}
206
207impl RbacConfigNote {
208    // CONSTANTS
209    // --------------------------------------------------------------------------------------------
210
211    /// The numbers of storage items the RbacConfig note script accepts.
212    ///
213    /// The layout is variable: `GrantRole` / `RevokeRole` use 4 items (`[variant, role_symbol,
214    /// account_suffix, account_prefix]`), `SetRoleAdmin` uses 3, and `RenounceRole` uses 2, so
215    /// every size in the range is used by one of the actions. Keep in sync with the `NUM_ITEMS_*`
216    /// constants in `rbac_config.masm`.
217    pub const NUM_STORAGE_ITEMS: NumStorageItems = NumStorageItems::Range { min: 2, max: 4 };
218
219    // PUBLIC ACCESSORS
220    // --------------------------------------------------------------------------------------------
221
222    /// Returns the script of the RbacConfig note.
223    pub fn script() -> NoteScript {
224        RBAC_CONFIG_SCRIPT.clone()
225    }
226
227    /// Returns the RbacConfig note script root.
228    pub fn script_root() -> NoteScriptRoot {
229        RBAC_CONFIG_SCRIPT.root()
230    }
231
232    /// Returns the account ID of the note's sender (the account authorized for the action).
233    pub fn sender(&self) -> AccountId {
234        self.sender
235    }
236
237    /// Returns the account ID of the managed account (the account the note is tagged for).
238    pub fn target(&self) -> AccountId {
239        self.target
240    }
241
242    /// Returns the management action carried by the note.
243    pub fn config(&self) -> &RbacConfig {
244        &self.config
245    }
246
247    /// Returns the note's serial number.
248    pub fn serial_number(&self) -> Word {
249        self.serial_number
250    }
251
252    /// Returns the attachments carried by the note, which always include a
253    /// [`NetworkAccountTarget`].
254    pub fn attachments(&self) -> &NoteAttachments {
255        &self.attachments
256    }
257}
258
259// BUILDER EXTENSIONS
260// ================================================================================================
261
262impl<S: rbac_config_note_builder::State> RbacConfigNoteBuilder<S> {
263    /// Adds a single attachment to the note.
264    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
265        self.attachments.push(attachment.into());
266        self
267    }
268
269    /// Adds multiple attachments to the note.
270    pub fn attachments(
271        mut self,
272        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
273    ) -> Self {
274        self.attachments.extend(attachments.into_iter().map(Into::into));
275        self
276    }
277}
278
279impl<S: rbac_config_note_builder::State> RbacConfigNoteBuilder<S>
280where
281    S::SerialNumber: rbac_config_note_builder::IsUnset,
282{
283    /// Draws a serial number from `rng` and sets it on the builder.
284    pub fn generate_serial_number(
285        self,
286        rng: &mut impl FeltRng,
287    ) -> RbacConfigNoteBuilder<rbac_config_note_builder::SetSerialNumber<S>> {
288        self.serial_number(rng.draw_word())
289    }
290}
291
292// CONVERSIONS
293// ================================================================================================
294
295impl From<RbacConfigNote> for Note {
296    fn from(note: RbacConfigNote) -> Self {
297        // RbacConfig notes carry no assets and are always public for network execution; the action
298        // and its arguments live in the note storage.
299        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
300            .with_tag(NoteTag::with_account_target(note.target));
301        let recipient = NoteRecipient::new(
302            note.serial_number,
303            RbacConfigNote::script(),
304            NoteStorage::from(note.config),
305        );
306        Note::with_attachments(NoteAssets::default(), metadata, recipient, note.attachments)
307    }
308}
309
310impl From<RbacConfigNote> for AccountTargetNetworkNote {
311    fn from(note: RbacConfigNote) -> Self {
312        AccountTargetNetworkNote::new(Note::from(note))
313            .expect("RbacConfig note is public and carries a network account target attachment")
314    }
315}
316
317// NOTE CONSUMPTION COST
318// ================================================================================================
319
320impl NoteConsumptionCost for RbacConfigNote {
321    fn consumption_cycles() -> u32 {
322        RBAC_CONFIG_CONSUMPTION_CYCLES
323    }
324}
325
326// TESTS
327// ================================================================================================
328
329#[cfg(test)]
330mod tests {
331    use assert_matches::assert_matches;
332    use miden_protocol::account::AccountType;
333    use miden_protocol::crypto::rand::RandomCoin;
334    use miden_protocol::note::NoteAttachmentScheme;
335
336    use super::*;
337    use crate::note::{NetworkAccountTargetError, NetworkNoteExt, NoteExecutionHint};
338
339    fn account_id(seed: u8) -> AccountId {
340        typed_account_id(seed, AccountType::Public)
341    }
342
343    fn typed_account_id(seed: u8, account_type: AccountType) -> AccountId {
344        AccountId::builder().account_type(account_type).build_with_seed([seed; 32])
345    }
346
347    fn role(name: &str) -> RoleSymbol {
348        RoleSymbol::new(name).expect("role symbol should be valid")
349    }
350
351    /// The builder produces a public, asset-less note tagged for the managed account.
352    #[test]
353    fn builder_builds_rbac_config_note() {
354        let mut rng = RandomCoin::new(Word::empty());
355        let managed = account_id(1);
356        let admin = account_id(2);
357        let grantee = account_id(3);
358
359        let note = RbacConfigNote::builder()
360            .sender(admin)
361            .target(managed)
362            .config(RbacConfig::GrantRole { role: role("MINTER"), account: grantee })
363            .generate_serial_number(&mut rng)
364            .build()
365            .unwrap();
366
367        assert_eq!(note.sender(), admin);
368        assert_eq!(note.target(), managed);
369
370        let note = Note::from(note);
371        assert_eq!(note.metadata().note_type(), NoteType::Public);
372        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(managed));
373        assert_eq!(note.assets().num_assets(), 0);
374    }
375
376    /// The builder attaches the network target for the managed account, so the note is a network
377    /// note without the caller having to add the attachment.
378    #[test]
379    fn builder_attaches_network_target() {
380        let mut rng = RandomCoin::new(Word::empty());
381        let managed = account_id(1);
382
383        let note = RbacConfigNote::builder()
384            .sender(account_id(2))
385            .target(managed)
386            .config(RbacConfig::RenounceRole { role: role("MINTER") })
387            .generate_serial_number(&mut rng)
388            .build()
389            .unwrap();
390
391        assert_eq!(note.attachments().num_attachments(), 1);
392
393        let network_note = AccountTargetNetworkNote::from(note);
394        assert_eq!(network_note.target_account_id(), managed);
395        assert_eq!(network_note.execution_hint(), NoteExecutionHint::Always);
396        assert!(network_note.as_note().is_network_note());
397    }
398
399    /// Caller-supplied attachments are kept in their order, with the bound network target appended.
400    #[test]
401    fn builder_keeps_caller_attachments() {
402        let mut rng = RandomCoin::new(Word::empty());
403        let managed = account_id(1);
404        let custom_scheme = NoteAttachmentScheme::new(64).unwrap();
405        let custom = NoteAttachment::with_word(custom_scheme, Word::from([7u32, 0, 0, 0]));
406
407        let note = RbacConfigNote::builder()
408            .attachment(custom.clone())
409            .sender(account_id(2))
410            .target(managed)
411            .config(RbacConfig::RenounceRole { role: role("MINTER") })
412            .generate_serial_number(&mut rng)
413            .build()
414            .unwrap();
415
416        // The target is appended, so the caller's attachment comes first.
417        assert_eq!(note.attachments().num_attachments(), 2);
418        assert_eq!(note.attachments().get(0), Some(&custom));
419
420        let network_note = AccountTargetNetworkNote::from(note);
421        assert_eq!(network_note.target_account_id(), managed);
422    }
423
424    /// A caller-supplied `NetworkAccountTarget` for another account is rejected rather than
425    /// silently coexisting with the note's own target.
426    #[test]
427    fn builder_rejects_target_for_other_account() {
428        let mut rng = RandomCoin::new(Word::empty());
429        let rogue_target =
430            NetworkAccountTarget::new(account_id(3), NoteExecutionHint::None).unwrap();
431
432        let err = RbacConfigNote::builder()
433            .attachment(rogue_target)
434            .sender(account_id(2))
435            .target(account_id(1))
436            .config(RbacConfig::RenounceRole { role: role("MINTER") })
437            .generate_serial_number(&mut rng)
438            .build()
439            .unwrap_err();
440
441        assert_matches!(err, NoteError::Other { source, .. } => {
442            assert_matches!(
443              *source.unwrap().downcast().unwrap(),
444              NetworkAccountTargetError::TargetMismatch { .. }
445            )
446        });
447    }
448
449    /// A non-public managed account cannot be a network target, so the builder rejects it.
450    #[test]
451    fn builder_rejects_non_public_account() {
452        let mut rng = RandomCoin::new(Word::empty());
453        let managed = typed_account_id(1, AccountType::Private);
454
455        let err = RbacConfigNote::builder()
456            .sender(account_id(2))
457            .target(managed)
458            .config(RbacConfig::RenounceRole { role: role("MINTER") })
459            .generate_serial_number(&mut rng)
460            .build()
461            .unwrap_err();
462
463        assert_matches!(err, NoteError::Other { source, .. } => {
464            assert_matches!(
465              *source.unwrap().downcast().unwrap(),
466              NetworkAccountTargetError::TargetNotPublic { .. }
467            )
468        });
469    }
470
471    /// `GrantRole` storage is `[variant, role_symbol, account_suffix, account_prefix]`.
472    #[test]
473    fn grant_role_storage_layout() {
474        let grantee = account_id(3);
475        let minter = role("MINTER");
476        let storage =
477            NoteStorage::from(RbacConfig::GrantRole { role: minter.clone(), account: grantee });
478
479        assert_eq!(
480            storage.items(),
481            &[
482                Felt::from(RbacConfig::VARIANT_GRANT_ROLE),
483                minter.as_element(),
484                grantee.suffix(),
485                grantee.prefix().as_felt(),
486            ]
487        );
488    }
489
490    /// `SetRoleAdmin` with `None` encodes a zero admin role (revert to the default `ADMIN` role).
491    #[test]
492    fn set_role_admin_default_storage_layout() {
493        let minter = role("MINTER");
494        let storage =
495            NoteStorage::from(RbacConfig::SetRoleAdmin { role: minter.clone(), admin_role: None });
496
497        assert_eq!(
498            storage.items(),
499            &[Felt::from(RbacConfig::VARIANT_SET_ROLE_ADMIN), minter.as_element(), Felt::ZERO]
500        );
501    }
502
503    /// `SetRoleAdmin` with `Some` encodes the delegated admin role symbol.
504    #[test]
505    fn set_role_admin_delegated_storage_layout() {
506        let minter = role("MINTER");
507        let admin = role("MINT_ADMIN");
508        let storage = NoteStorage::from(RbacConfig::SetRoleAdmin {
509            role: minter.clone(),
510            admin_role: Some(admin.clone()),
511        });
512
513        assert_eq!(
514            storage.items(),
515            &[
516                Felt::from(RbacConfig::VARIANT_SET_ROLE_ADMIN),
517                minter.as_element(),
518                admin.as_element(),
519            ]
520        );
521    }
522
523    /// `RenounceRole` storage is `[variant, role_symbol]`.
524    #[test]
525    fn renounce_role_storage_layout() {
526        let minter = role("MINTER");
527        let storage = NoteStorage::from(RbacConfig::RenounceRole { role: minter.clone() });
528
529        assert_eq!(
530            storage.items(),
531            &[Felt::from(RbacConfig::VARIANT_RENOUNCE_ROLE), minter.as_element()]
532        );
533    }
534}