Skip to main content

miden_standards/account/access/
authority.rs

1use alloc::collections::BTreeMap;
2use alloc::vec;
3
4use miden_protocol::account::component::{
5    AccountComponentCode,
6    AccountComponentMetadata,
7    FeltSchema,
8    SchemaType,
9    StorageSchema,
10    StorageSlotSchema,
11};
12use miden_protocol::account::{
13    AccountComponent,
14    AccountProcedureRoot,
15    AccountStorage,
16    RoleSymbol,
17    StorageMap,
18    StorageMapKey,
19    StorageSlot,
20    StorageSlotContent,
21    StorageSlotName,
22};
23use miden_protocol::errors::{AccountError, RoleSymbolError};
24use miden_protocol::utils::sync::LazyLock;
25use miden_protocol::{Felt, Word};
26use thiserror::Error;
27
28use crate::account::account_component_code;
29use crate::procedure_root;
30
31// CONSTANTS
32// ================================================================================================
33
34account_component_code!(AUTHORITY_CODE, "miden-standards-access-authority.masp");
35
36// PROCEDURE ROOTS
37// ================================================================================================
38
39/// MASL library namespace used for procedure-root lookups. Distinct from [`Authority::NAME`], which
40/// mirrors the standards-side MASM module path.
41const AUTHORITY_LIBRARY_PATH: &str = "miden::standards::components::access::authority";
42
43procedure_root!(
44    AUTHORITY_FREEZE,
45    AUTHORITY_LIBRARY_PATH,
46    Authority::FREEZE_PROC_NAME,
47    Authority::code()
48);
49
50procedure_root!(
51    AUTHORITY_UNFREEZE,
52    AUTHORITY_LIBRARY_PATH,
53    Authority::UNFREEZE_PROC_NAME,
54    Authority::code()
55);
56
57static AUTHORITY_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
58    StorageSlotName::new("miden::standards::access::authority::authority_config")
59        .expect("storage slot name should be valid")
60});
61
62static AUTHORITY_PROCEDURE_ROLES_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
63    StorageSlotName::new("miden::standards::access::authority::procedure_roles")
64        .expect("storage slot name should be valid")
65});
66
67/// Authority value written to the storage slot for [`Authority::AuthControlled`].
68const AUTH_CONTROLLED: u8 = 0;
69/// Authority value written to the storage slot for [`Authority::OwnerControlled`].
70const OWNER_CONTROLLED: u8 = 1;
71/// Authority value written to the storage slot for [`Authority::RbacControlled`].
72const RBAC_CONTROLLED: u8 = 2;
73
74// AUTHORITY
75// ================================================================================================
76
77/// Identifies which authority is allowed to invoke an authority-gated procedure on an account.
78///
79/// Components that gate state-mutating procedures (such as
80/// [`TokenPolicyManager`][crate::account::policies::TokenPolicyManager] for `set_mint_policy` /
81/// `set_burn_policy`, or the fungible token metadata setters) consult this shared slot via the
82/// MASM helper `authority::assert_authorized`. Installing the [`Authority`] component on an account
83/// thus selects the gating mode for *all* such procedures in one place.
84///
85/// # Safety invariant for [`Authority::AuthControlled`]
86///
87/// Because `assert_authorized` is a no-op under `AuthControlled`, the account's auth component
88/// is the **sole** gate for every authority-gated setter. The auth component MUST therefore
89/// authenticate every such setter root, otherwise the setters become permissionless.
90///
91/// # Per-procedure roles under [`Authority::RbacControlled`]
92///
93/// Under RBAC, each gated procedure can be assigned its own role via `procedure_roles`, keyed by
94/// the procedure's [`AccountProcedureRoot`] (e.g. `pause` → `PAUSER`, `unpause` → `UNPAUSER`). At
95/// runtime `assert_authorized` identifies the calling procedure via the `caller` instruction and
96/// looks up its role. A procedure without a mapping falls back to the `ADMIN` role check.
97///
98/// # Emergency switch (`is_frozen`)
99///
100/// The component includes an `is_frozen` flag. If it is `true`, all procedures that call
101/// `assert_authorized` would panic, effectively freezing them. Accounts are always constructed
102/// unfrozen.
103///
104/// The flag is toggled via `freeze` / `unfreeze`. Under [`Authority::OwnerControlled`] these are
105/// gated on the [`Ownable2Step`][crate::account::access::Ownable2Step] owner; under
106/// [`Authority::RbacControlled`] they resolve their role from the role map (e.g. `FREEZER` /
107/// `UNFREEZER`), defaulting to the `ADMIN` role. Both bypass the frozen flag itself so the switch
108/// can always be toggled.
109///
110/// This flag has no effect under [`Authority::AuthControlled`], where `freeze` / `unfreeze` panic
111/// (there is no owner and no role graph).
112///
113/// Storage layout:
114/// - Value slot: `[authority, is_frozen, 0, 0]`.
115/// - Map slot (only under RBAC): `procedure_root` → `[role_symbol, 0, 0, 0]`.
116#[repr(u8)]
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub enum Authority {
120    /// Authority is the account's auth component.
121    AuthControlled = AUTH_CONTROLLED,
122    /// Authority is the [`Ownable2Step`][crate::account::access::Ownable2Step] owner.
123    OwnerControlled = OWNER_CONTROLLED,
124    /// Authority is membership in an RBAC role, resolved per gated procedure.
125    ///
126    /// `procedure_roles` maps a gated procedure's [`AccountProcedureRoot`] to the role required to
127    /// invoke it. Requires the
128    /// [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be
129    /// installed on the account. the MASM helper calls into `rbac::assert_sender_has_role` and will
130    /// fail to link otherwise.
131    RbacControlled {
132        procedure_roles: BTreeMap<AccountProcedureRoot, RoleSymbol>,
133    } = RBAC_CONTROLLED,
134}
135
136impl Authority {
137    /// The name of the component.
138    pub const NAME: &'static str = "miden::standards::access::authority";
139
140    /// Name of the owner-gated procedure that freezes the authority-gated surface.
141    const FREEZE_PROC_NAME: &'static str = "freeze";
142    /// Name of the owner-gated procedure that unfreezes the authority-gated surface.
143    const UNFREEZE_PROC_NAME: &'static str = "unfreeze";
144
145    /// Returns the [`AccountComponentCode`] of this component.
146    pub fn code() -> &'static AccountComponentCode {
147        &AUTHORITY_CODE
148    }
149
150    // PUBLIC ACCESSORS
151    // --------------------------------------------------------------------------------------------
152
153    /// Returns the procedure root of the `freeze` emergency switch.
154    ///
155    /// Under [`Authority::OwnerControlled`] this is gated on the owner. Under
156    /// [`Authority::RbacControlled`] it may be assigned its own role via the role map (e.g.
157    /// `FREEZER`); when unmapped it falls back to the `ADMIN` role. Unlike ordinary gated
158    /// procedures it bypasses the frozen flag so it can always be toggled.
159    pub fn freeze_root() -> AccountProcedureRoot {
160        *AUTHORITY_FREEZE
161    }
162
163    /// Returns the procedure root of the `unfreeze` emergency switch.
164    ///
165    /// Under [`Authority::OwnerControlled`] this is gated on the owner. Under
166    /// [`Authority::RbacControlled`] it may be assigned its own role via the role map (e.g.
167    /// `UNFREEZER`); when unmapped it falls back to the `ADMIN` role. Unlike ordinary gated
168    /// procedures it bypasses the frozen flag so it can always be toggled.
169    pub fn unfreeze_root() -> AccountProcedureRoot {
170        *AUTHORITY_UNFREEZE
171    }
172
173    /// Returns the [`StorageSlotName`] holding the authority configuration.
174    pub fn authority_slot() -> &'static StorageSlotName {
175        &AUTHORITY_SLOT_NAME
176    }
177
178    /// Returns the [`StorageSlotName`] holding the per-procedure role map (RBAC only).
179    pub fn procedure_roles_slot() -> &'static StorageSlotName {
180        &AUTHORITY_PROCEDURE_ROLES_SLOT_NAME
181    }
182
183    /// Reads the authority configuration from account storage.
184    pub fn try_from_storage(storage: &AccountStorage) -> Result<Self, AuthorityError> {
185        let word = Self::read_config_word(storage)?;
186
187        let discriminant: u8 = word[0]
188            .as_canonical_u64()
189            .try_into()
190            .map_err(|_| AuthorityError::InvalidAuthority(word[0].as_canonical_u64()))?;
191
192        match discriminant {
193            AUTH_CONTROLLED => Ok(Self::AuthControlled),
194            OWNER_CONTROLLED => Ok(Self::OwnerControlled),
195            RBAC_CONTROLLED => {
196                let procedure_roles = Self::read_roles_from_storage(storage)?;
197                Ok(Self::RbacControlled { procedure_roles })
198            },
199            other => Err(AuthorityError::InvalidAuthority(other.into())),
200        }
201    }
202
203    /// Reads the `is_frozen` emergency-switch flag from account storage.
204    ///
205    /// Returns `true` if the account's authority-gated surface is currently frozen (every
206    /// procedure that calls `assert_authorized` panics until it is unfrozen).
207    pub fn try_read_frozen(storage: &AccountStorage) -> Result<bool, AuthorityError> {
208        let word = Self::read_config_word(storage)?;
209
210        Ok(word[1] != Felt::ZERO)
211    }
212
213    /// Returns the [`AccountComponentMetadata`] for this configuration.
214    pub fn component_metadata(&self) -> AccountComponentMetadata {
215        let mut slots = vec![(
216            AUTHORITY_SLOT_NAME.clone(),
217            StorageSlotSchema::value(
218                "Authority configuration",
219                [
220                    FeltSchema::u8("authority"),
221                    FeltSchema::u8("is_frozen"),
222                    FeltSchema::new_void(),
223                    FeltSchema::new_void(),
224                ],
225            ),
226        )];
227
228        if matches!(self, Authority::RbacControlled { .. }) {
229            slots.push((
230                AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.clone(),
231                StorageSlotSchema::map(
232                    "Per-procedure role assignment (procedure root -> role symbol)",
233                    SchemaType::native_word(),
234                    SchemaType::role_symbol(),
235                ),
236            ));
237        }
238
239        let storage_schema = StorageSchema::new(slots).expect("storage schema should be valid");
240
241        AccountComponentMetadata::new(Self::NAME)
242            .with_description(
243                "Account-wide authority shared by procedures that gate state-mutating \
244                 operations behind auth-only, owner-based, or RBAC role-based checks",
245            )
246            .with_storage_schema(storage_schema)
247    }
248
249    // PRIVATE HELPERS
250    // --------------------------------------------------------------------------------------------
251
252    /// Returns the discriminant byte written to `word[0]` of the authority slot.
253    fn as_u8(&self) -> u8 {
254        match self {
255            Authority::AuthControlled => AUTH_CONTROLLED,
256            Authority::OwnerControlled => OWNER_CONTROLLED,
257            Authority::RbacControlled { .. } => RBAC_CONTROLLED,
258        }
259    }
260
261    /// Encodes the authority configuration value slot word: `[authority, is_frozen, 0, 0]`.
262    fn to_word(&self) -> Word {
263        Word::new([Felt::from(self.as_u8()), Felt::ZERO, Felt::ZERO, Felt::ZERO])
264    }
265
266    /// Reads and validates the authority value-slot word `[authority, is_frozen, 0, 0]`.
267    ///
268    /// Enforces the canonical encoding on read: the reserved felts `word[2]` and `word[3]` must be
269    /// zero, and `is_frozen` (`word[1]`) must be a boolean (`0` or `1`) - the exact form the write
270    /// path (`to_word` plus the MASM freeze/unfreeze switch) always produces.
271    fn read_config_word(storage: &AccountStorage) -> Result<Word, AuthorityError> {
272        let word = storage
273            .get_item(Self::authority_slot())
274            .map_err(AuthorityError::MissingStorageSlot)?;
275
276        if word[2] != Felt::ZERO || word[3] != Felt::ZERO || word[1].as_canonical_u64() > 1 {
277            return Err(AuthorityError::NonCanonicalConfig);
278        }
279
280        Ok(word)
281    }
282
283    /// Reconstructs the per-procedure role map from the procedure-roles storage slot.
284    fn read_roles_from_storage(
285        storage: &AccountStorage,
286    ) -> Result<BTreeMap<AccountProcedureRoot, RoleSymbol>, AuthorityError> {
287        let slot = storage
288            .slots()
289            .iter()
290            .find(|slot| slot.name().id() == AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.id())
291            .ok_or(AuthorityError::MissingProcedureRolesSlot)?;
292
293        let StorageSlotContent::Map(map) = slot.content() else {
294            return Err(AuthorityError::MissingProcedureRolesSlot);
295        };
296
297        let mut roles = BTreeMap::new();
298        for (key, value) in map.entries() {
299            // Enforce the canonical encoding on read: the reserved felts must be zero.
300            if value[1..4].iter().any(|v| *v != Felt::ZERO) {
301                return Err(AuthorityError::NonCanonicalConfig);
302            }
303            let proc_root = AccountProcedureRoot::from_raw(key.as_word());
304            let role = RoleSymbol::try_from(value[0]).map_err(AuthorityError::InvalidRoleSymbol)?;
305            roles.insert(proc_root, role);
306        }
307
308        Ok(roles)
309    }
310}
311
312// TRAIT IMPLEMENTATIONS
313// ================================================================================================
314
315impl From<Authority> for AccountComponent {
316    fn from(value: Authority) -> Self {
317        let metadata = value.component_metadata();
318
319        let mut slots = vec![StorageSlot::with_value(AUTHORITY_SLOT_NAME.clone(), value.to_word())];
320
321        if let Authority::RbacControlled { procedure_roles } = value {
322            let entries = procedure_roles.into_iter().map(|(proc_root, role)| {
323                (StorageMapKey::new(proc_root.as_word()), role_value_word(&role))
324            });
325            slots.push(StorageSlot::with_map(
326                AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.clone(),
327                StorageMap::with_entries(entries)
328                    .expect("authority procedure-roles map should be valid"),
329            ));
330        }
331
332        AccountComponent::new(Authority::code().clone(), slots, metadata).expect(
333            "authority component should satisfy the requirements of a valid account component",
334        )
335    }
336}
337
338/// Encodes a role symbol as a map value word: `[role_symbol, 0, 0, 0]`.
339fn role_value_word(role: &RoleSymbol) -> Word {
340    Word::new([role.into(), Felt::ZERO, Felt::ZERO, Felt::ZERO])
341}
342
343// AUTHORITY ERROR
344// ================================================================================================
345
346/// Errors raised when reading or parsing an [`Authority`] from storage.
347#[derive(Debug, Error)]
348pub enum AuthorityError {
349    #[error("invalid authority value: {0}")]
350    InvalidAuthority(u64),
351    #[error("authority configuration word is not in canonical form")]
352    NonCanonicalConfig,
353    #[error("invalid role symbol in authority storage")]
354    InvalidRoleSymbol(#[source] RoleSymbolError),
355    #[error("failed to read authority slot from storage")]
356    MissingStorageSlot(#[source] AccountError),
357    #[error("authority procedure-roles slot is missing or not a map")]
358    MissingProcedureRolesSlot,
359}
360
361#[cfg(test)]
362mod tests {
363    use assert_matches::assert_matches;
364
365    use super::*;
366
367    /// Procedure-root key of the single entry inserted by [`rbac_storage_with_role_value`].
368    const ROLE_KEY_WORD: [u32; 4] = [1, 2, 3, 4];
369
370    /// Builds account storage whose authority value slot holds `word`.
371    fn storage_with_config(word: Word) -> AccountStorage {
372        let slot = StorageSlot::with_value(Authority::authority_slot().clone(), word);
373        AccountStorage::new(vec![slot]).expect("storage should be valid")
374    }
375
376    /// Builds RBAC account storage whose procedure-roles map holds a single entry, keyed by
377    /// [`ROLE_KEY_WORD`], with `role_value` as its value word.
378    fn rbac_storage_with_role_value(role_value: Word) -> AccountStorage {
379        let config = StorageSlot::with_value(
380            Authority::authority_slot().clone(),
381            Word::from([u32::from(RBAC_CONTROLLED), 0, 0, 0]),
382        );
383        let key = StorageMapKey::new(Word::from(ROLE_KEY_WORD));
384        let map = StorageMap::with_entries([(key, role_value)]).expect("map should be valid");
385        let roles = StorageSlot::with_map(Authority::procedure_roles_slot().clone(), map);
386        AccountStorage::new(vec![config, roles]).expect("storage should be valid")
387    }
388
389    #[test]
390    fn canonical_config_is_accepted() {
391        // AuthControlled, not frozen.
392        let storage = storage_with_config(Word::from([u32::from(AUTH_CONTROLLED), 0, 0, 0]));
393        assert_eq!(Authority::try_from_storage(&storage).unwrap(), Authority::AuthControlled);
394        assert!(!Authority::try_read_frozen(&storage).unwrap());
395
396        // OwnerControlled, frozen.
397        let storage = storage_with_config(Word::from([u32::from(OWNER_CONTROLLED), 1, 0, 0]));
398        assert_eq!(Authority::try_from_storage(&storage).unwrap(), Authority::OwnerControlled);
399        assert!(Authority::try_read_frozen(&storage).unwrap());
400    }
401
402    #[test]
403    fn non_zero_reserved_felt_is_rejected() {
404        // word[3] carries unexpected trailing data.
405        let storage = storage_with_config(Word::from([u32::from(OWNER_CONTROLLED), 0, 0, 7]));
406        assert!(matches!(
407            Authority::try_from_storage(&storage),
408            Err(AuthorityError::NonCanonicalConfig)
409        ));
410        assert!(matches!(
411            Authority::try_read_frozen(&storage),
412            Err(AuthorityError::NonCanonicalConfig)
413        ));
414
415        // word[2] carries unexpected trailing data.
416        let storage = storage_with_config(Word::from([u32::from(OWNER_CONTROLLED), 0, 5, 0]));
417        assert!(matches!(
418            Authority::try_from_storage(&storage),
419            Err(AuthorityError::NonCanonicalConfig)
420        ));
421    }
422
423    #[test]
424    fn non_boolean_frozen_flag_is_rejected() {
425        // is_frozen (word[1]) must be 0 or 1; 2 is non-canonical.
426        let storage = storage_with_config(Word::from([u32::from(AUTH_CONTROLLED), 2, 0, 0]));
427        assert!(matches!(
428            Authority::try_from_storage(&storage),
429            Err(AuthorityError::NonCanonicalConfig)
430        ));
431        assert!(matches!(
432            Authority::try_read_frozen(&storage),
433            Err(AuthorityError::NonCanonicalConfig)
434        ));
435    }
436
437    #[test]
438    fn non_zero_reserved_felt_in_role_value_is_rejected() {
439        let role = RoleSymbol::new("ADMIN").unwrap();
440        let role_felt: Felt = (&role).into();
441        let expected_root = AccountProcedureRoot::from_raw(Word::from(ROLE_KEY_WORD));
442
443        // A canonical role value word `[role, 0, 0, 0]` is accepted and parses the configured role.
444        let storage = rbac_storage_with_role_value(Word::new([
445            role_felt,
446            Felt::ZERO,
447            Felt::ZERO,
448            Felt::ZERO,
449        ]));
450        assert_matches!(
451            Authority::try_from_storage(&storage),
452            Ok(Authority::RbacControlled { procedure_roles })
453                if procedure_roles.get(&expected_root) == Some(&role)
454        );
455
456        // A non-zero reserved felt in the role value word carries unexpected trailing data.
457        let storage = rbac_storage_with_role_value(Word::new([
458            role_felt,
459            Felt::ZERO,
460            Felt::from(9u8),
461            Felt::ZERO,
462        ]));
463        assert_matches!(
464            Authority::try_from_storage(&storage),
465            Err(AuthorityError::NonCanonicalConfig)
466        );
467    }
468}