Skip to main content

miden_standards/note/
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};
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    // SELECTORS
72    // --------------------------------------------------------------------------------------------
73
74    // Config note selectors stored in the first storage item. Keep in sync with `rbac_config.masm`.
75    const SELECTOR_GRANT_ROLE: u8 = 0;
76    const SELECTOR_REVOKE_ROLE: u8 = 1;
77    const SELECTOR_SET_ROLE_ADMIN: u8 = 2;
78    const SELECTOR_RENOUNCE_ROLE: u8 = 3;
79
80    /// Returns the note storage values encoding this action, laid out as `[selector, ..args]`.
81    fn to_storage_values(&self) -> Vec<Felt> {
82        match self {
83            RbacConfig::GrantRole { role, account } => {
84                vec![
85                    Felt::from(Self::SELECTOR_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::SELECTOR_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::SELECTOR_SET_ROLE_ADMIN), role.as_element(), admin_role]
104            },
105            RbacConfig::RenounceRole { role } => {
106                vec![Felt::from(Self::SELECTOR_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 a selector in the note's 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/// Construct one with the [builder](RbacConfigNote::builder); convert it into a protocol [`Note`]
143/// infallibly via `Note::from`.
144///
145/// ## Security considerations
146///
147/// A created note is an unordered, unexpiring, uncancellable instruction — treat it as a
148/// standing capability and do not create role-management notes ahead of need. Consumption order
149/// is chosen by whoever consumes the note, so when rotating a role, wait for the successor's
150/// grant to commit before issuing any revoke or renounce (a note that fails because its sender
151/// currently lacks the role stays pending and revives if the sender regains it).
152#[derive(Debug, Clone)]
153pub struct RbacConfigNote {
154    sender: AccountId,
155    target: AccountId,
156    config: RbacConfig,
157    serial_number: Word,
158    attachments: NoteAttachments,
159}
160
161#[bon::bon]
162impl RbacConfigNote {
163    /// Builds a new [`RbacConfigNote`] that applies `config` to `account`.
164    ///
165    /// The note is bound to `account` by a [`NetworkAccountTarget`] attachment that the builder
166    /// appends unless the caller already supplied one for `account`.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if:
171    /// - `account` is not a public account (the note is bound to it via a `NetworkAccountTarget`,
172    ///   which requires a public target).
173    /// - the attachments carry a `NetworkAccountTarget` for an account other than `account`.
174    /// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
175    ///   attachment occupies one of the available slots when the caller does not supply it.
176    #[builder]
177    pub fn new(
178        #[builder(field)] mut attachments: Vec<NoteAttachment>,
179        sender: AccountId,
180        target: AccountId,
181        config: RbacConfig,
182        serial_number: Word,
183    ) -> Result<Self, NoteError> {
184        // The note script asserts that the consuming account matches this target before
185        // dispatching.
186        NetworkAccountTarget::ensure_presence(&mut attachments, target).map_err(|err| {
187            NoteError::other_with_source(
188                "failed to bind the RbacConfig note to its target account",
189                err,
190            )
191        })?;
192        let attachments = NoteAttachments::new(attachments)?;
193
194        Ok(Self {
195            sender,
196            target,
197            config,
198            serial_number,
199            attachments,
200        })
201    }
202}
203
204impl RbacConfigNote {
205    // CONSTANTS
206    // --------------------------------------------------------------------------------------------
207
208    /// Upper bound on the number of storage items of an RbacConfig note.
209    ///
210    /// The layout is variable: `GrantRole` / `RevokeRole` use 4 items (`[selector, role_symbol,
211    /// account_suffix, account_prefix]`), `SetRoleAdmin` uses 3, and `RenounceRole` uses 2.
212    pub const MAX_NUM_STORAGE_ITEMS: usize = 4;
213
214    // PUBLIC ACCESSORS
215    // --------------------------------------------------------------------------------------------
216
217    /// Returns the script of the RbacConfig note.
218    pub fn script() -> NoteScript {
219        RBAC_CONFIG_SCRIPT.clone()
220    }
221
222    /// Returns the RbacConfig note script root.
223    pub fn script_root() -> NoteScriptRoot {
224        RBAC_CONFIG_SCRIPT.root()
225    }
226
227    /// Returns the account ID of the note's sender (the account authorized for the action).
228    pub fn sender(&self) -> AccountId {
229        self.sender
230    }
231
232    /// Returns the account ID of the managed account (the account the note is tagged for).
233    pub fn account(&self) -> AccountId {
234        self.target
235    }
236
237    /// Returns the management action carried by the note.
238    pub fn config(&self) -> &RbacConfig {
239        &self.config
240    }
241
242    /// Returns the note's serial number.
243    pub fn serial_number(&self) -> Word {
244        self.serial_number
245    }
246
247    /// Returns the attachments carried by the note, which always include a
248    /// [`NetworkAccountTarget`].
249    pub fn attachments(&self) -> &NoteAttachments {
250        &self.attachments
251    }
252}
253
254// BUILDER EXTENSIONS
255// ================================================================================================
256
257impl<S: rbac_config_note_builder::State> RbacConfigNoteBuilder<S> {
258    /// Adds a single attachment to the note.
259    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
260        self.attachments.push(attachment.into());
261        self
262    }
263
264    /// Adds multiple attachments to the note.
265    pub fn attachments(
266        mut self,
267        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
268    ) -> Self {
269        self.attachments.extend(attachments.into_iter().map(Into::into));
270        self
271    }
272}
273
274impl<S: rbac_config_note_builder::State> RbacConfigNoteBuilder<S>
275where
276    S::SerialNumber: rbac_config_note_builder::IsUnset,
277{
278    /// Draws a serial number from `rng` and sets it on the builder.
279    pub fn generate_serial_number(
280        self,
281        rng: &mut impl FeltRng,
282    ) -> RbacConfigNoteBuilder<rbac_config_note_builder::SetSerialNumber<S>> {
283        self.serial_number(rng.draw_word())
284    }
285}
286
287// CONVERSIONS
288// ================================================================================================
289
290impl From<RbacConfigNote> for Note {
291    fn from(note: RbacConfigNote) -> Self {
292        // RbacConfig notes carry no assets and are always public for network execution; the action
293        // and its arguments live in the note storage.
294        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
295            .with_tag(NoteTag::with_account_target(note.target));
296        let recipient = NoteRecipient::new(
297            note.serial_number,
298            RbacConfigNote::script(),
299            NoteStorage::from(note.config),
300        );
301        Note::with_attachments(NoteAssets::default(), metadata, recipient, note.attachments)
302    }
303}
304
305impl From<RbacConfigNote> for AccountTargetNetworkNote {
306    fn from(note: RbacConfigNote) -> Self {
307        AccountTargetNetworkNote::new(Note::from(note))
308            .expect("RbacConfig note is public and carries a network account target attachment")
309    }
310}
311
312// NOTE CONSUMPTION COST
313// ================================================================================================
314
315impl NoteConsumptionCost for RbacConfigNote {
316    fn consumption_cycles() -> u32 {
317        RBAC_CONFIG_CONSUMPTION_CYCLES
318    }
319}
320
321// TESTS
322// ================================================================================================
323
324#[cfg(test)]
325mod tests {
326    use assert_matches::assert_matches;
327    use miden_protocol::account::AccountType;
328    use miden_protocol::crypto::rand::RandomCoin;
329    use miden_protocol::note::NoteAttachmentScheme;
330
331    use super::*;
332    use crate::note::{NetworkAccountTargetError, NetworkNoteExt, NoteExecutionHint};
333
334    fn account_id(seed: u8) -> AccountId {
335        typed_account_id(seed, AccountType::Public)
336    }
337
338    fn typed_account_id(seed: u8, account_type: AccountType) -> AccountId {
339        AccountId::builder().account_type(account_type).build_with_seed([seed; 32])
340    }
341
342    fn role(name: &str) -> RoleSymbol {
343        RoleSymbol::new(name).expect("role symbol should be valid")
344    }
345
346    /// The builder produces a public, asset-less note tagged for the managed account.
347    #[test]
348    fn builder_builds_rbac_config_note() {
349        let mut rng = RandomCoin::new(Word::empty());
350        let managed = account_id(1);
351        let admin = account_id(2);
352        let grantee = account_id(3);
353
354        let note = RbacConfigNote::builder()
355            .sender(admin)
356            .target(managed)
357            .config(RbacConfig::GrantRole { role: role("MINTER"), account: grantee })
358            .generate_serial_number(&mut rng)
359            .build()
360            .unwrap();
361
362        assert_eq!(note.sender(), admin);
363        assert_eq!(note.account(), managed);
364
365        let note = Note::from(note);
366        assert_eq!(note.metadata().note_type(), NoteType::Public);
367        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(managed));
368        assert_eq!(note.assets().num_assets(), 0);
369    }
370
371    /// The builder attaches the network target for the managed account, so the note is a network
372    /// note without the caller having to add the attachment.
373    #[test]
374    fn builder_attaches_network_target() {
375        let mut rng = RandomCoin::new(Word::empty());
376        let managed = account_id(1);
377
378        let note = RbacConfigNote::builder()
379            .sender(account_id(2))
380            .target(managed)
381            .config(RbacConfig::RenounceRole { role: role("MINTER") })
382            .generate_serial_number(&mut rng)
383            .build()
384            .unwrap();
385
386        assert_eq!(note.attachments().num_attachments(), 1);
387
388        let network_note = AccountTargetNetworkNote::from(note);
389        assert_eq!(network_note.target_account_id(), managed);
390        assert_eq!(network_note.execution_hint(), NoteExecutionHint::Always);
391        assert!(network_note.as_note().is_network_note());
392    }
393
394    /// Caller-supplied attachments are kept in their order, with the bound network target appended.
395    #[test]
396    fn builder_keeps_caller_attachments() {
397        let mut rng = RandomCoin::new(Word::empty());
398        let managed = account_id(1);
399        let custom_scheme = NoteAttachmentScheme::new(64).unwrap();
400        let custom = NoteAttachment::with_word(custom_scheme, Word::from([7u32, 0, 0, 0]));
401
402        let note = RbacConfigNote::builder()
403            .attachment(custom.clone())
404            .sender(account_id(2))
405            .target(managed)
406            .config(RbacConfig::RenounceRole { role: role("MINTER") })
407            .generate_serial_number(&mut rng)
408            .build()
409            .unwrap();
410
411        // The target is appended, so the caller's attachment comes first.
412        assert_eq!(note.attachments().num_attachments(), 2);
413        assert_eq!(note.attachments().get(0), Some(&custom));
414
415        let network_note = AccountTargetNetworkNote::from(note);
416        assert_eq!(network_note.target_account_id(), managed);
417    }
418
419    /// A caller-supplied `NetworkAccountTarget` for another account is rejected rather than
420    /// silently coexisting with the note's own target.
421    #[test]
422    fn builder_rejects_target_for_other_account() {
423        let mut rng = RandomCoin::new(Word::empty());
424        let rogue_target =
425            NetworkAccountTarget::new(account_id(3), NoteExecutionHint::None).unwrap();
426
427        let err = RbacConfigNote::builder()
428            .attachment(rogue_target)
429            .sender(account_id(2))
430            .target(account_id(1))
431            .config(RbacConfig::RenounceRole { role: role("MINTER") })
432            .generate_serial_number(&mut rng)
433            .build()
434            .unwrap_err();
435
436        assert_matches!(err, NoteError::Other { source, .. } => {
437            assert_matches!(
438              *source.unwrap().downcast().unwrap(),
439              NetworkAccountTargetError::TargetMismatch { .. }
440            )
441        });
442    }
443
444    /// A non-public managed account cannot be a network target, so the builder rejects it.
445    #[test]
446    fn builder_rejects_non_public_account() {
447        let mut rng = RandomCoin::new(Word::empty());
448        let managed = typed_account_id(1, AccountType::Private);
449
450        let err = RbacConfigNote::builder()
451            .sender(account_id(2))
452            .target(managed)
453            .config(RbacConfig::RenounceRole { role: role("MINTER") })
454            .generate_serial_number(&mut rng)
455            .build()
456            .unwrap_err();
457
458        assert_matches!(err, NoteError::Other { source, .. } => {
459            assert_matches!(
460              *source.unwrap().downcast().unwrap(),
461              NetworkAccountTargetError::TargetNotPublic { .. }
462            )
463        });
464    }
465
466    /// `GrantRole` storage is `[selector, role_symbol, account_suffix, account_prefix]`.
467    #[test]
468    fn grant_role_storage_layout() {
469        let grantee = account_id(3);
470        let minter = role("MINTER");
471        let storage =
472            NoteStorage::from(RbacConfig::GrantRole { role: minter.clone(), account: grantee });
473
474        assert_eq!(
475            storage.items(),
476            &[
477                Felt::from(RbacConfig::SELECTOR_GRANT_ROLE),
478                minter.as_element(),
479                grantee.suffix(),
480                grantee.prefix().as_felt(),
481            ]
482        );
483    }
484
485    /// `SetRoleAdmin` with `None` encodes a zero admin role (revert to the default `ADMIN` role).
486    #[test]
487    fn set_role_admin_default_storage_layout() {
488        let minter = role("MINTER");
489        let storage =
490            NoteStorage::from(RbacConfig::SetRoleAdmin { role: minter.clone(), admin_role: None });
491
492        assert_eq!(
493            storage.items(),
494            &[Felt::from(RbacConfig::SELECTOR_SET_ROLE_ADMIN), minter.as_element(), Felt::ZERO]
495        );
496    }
497
498    /// `SetRoleAdmin` with `Some` encodes the delegated admin role symbol.
499    #[test]
500    fn set_role_admin_delegated_storage_layout() {
501        let minter = role("MINTER");
502        let admin = role("MINT_ADMIN");
503        let storage = NoteStorage::from(RbacConfig::SetRoleAdmin {
504            role: minter.clone(),
505            admin_role: Some(admin.clone()),
506        });
507
508        assert_eq!(
509            storage.items(),
510            &[
511                Felt::from(RbacConfig::SELECTOR_SET_ROLE_ADMIN),
512                minter.as_element(),
513                admin.as_element(),
514            ]
515        );
516    }
517
518    /// `RenounceRole` storage is `[selector, role_symbol]`.
519    #[test]
520    fn renounce_role_storage_layout() {
521        let minter = role("MINTER");
522        let storage = NoteStorage::from(RbacConfig::RenounceRole { role: minter.clone() });
523
524        assert_eq!(
525            storage.items(),
526            &[Felt::from(RbacConfig::SELECTOR_RENOUNCE_ROLE), minter.as_element()]
527        );
528    }
529}