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/// # Freeze-only actor (incident-response "panic button")
114///
115/// A second actor that can freeze the account in an incident but can never re-open it, or authorize
116/// anything else, needs no dedicated component: it is a plain [`Authority::RbacControlled`] role
117/// assignment. Map `freeze` to a role of its own, map `unfreeze` to a *different* role, and grant
118/// the incident responder only the former:
119///
120/// ```no_run
121/// use std::collections::BTreeMap;
122///
123/// use miden_protocol::account::{AccountBuilder, RoleSymbol};
124/// use miden_standards::account::access::{AccessControl, Authority};
125/// # let admin: miden_protocol::account::AccountId = unimplemented!();
126/// # let init_seed = [0u8; 32];
127///
128/// let procedure_roles = BTreeMap::from([
129///     (Authority::freeze_root(), RoleSymbol::new("FREEZER")?),
130///     (Authority::unfreeze_root(), RoleSymbol::new("UNFREEZER")?),
131/// ]);
132///
133/// AccountBuilder::new(init_seed).with_components(AccessControl::Rbac { admin, procedure_roles });
134///
135/// // Then grant `FREEZER` to the incident responder and `UNFREEZER` to the recovery authority
136/// // through the `RoleBasedAccessControl` component's `grant_role`.
137/// # Ok::<(), miden_protocol::errors::RoleSymbolError>(())
138/// ```
139///
140/// This yields the intended asymmetry: freezing is available to the `FREEZER`, re-opening is not.
141/// A compromised freeze-only actor can at worst deny service by freezing the account; it can never
142/// keep the account open, grant roles, move assets, or invoke any other gated procedure.
143///
144/// Two things to get right when wiring this up:
145///
146/// - Map `unfreeze` explicitly, or leave it unmapped and keep the freeze-only actor out of `ADMIN`.
147///   An unmapped procedure falls back to the `ADMIN` role, so a freeze-only actor that also holds
148///   `ADMIN` could re-open the account and defeat the asymmetry.
149/// - The pattern requires `RbacControlled`. Under [`Authority::OwnerControlled`] the owner is the
150///   only emergency authority, and under [`Authority::AuthControlled`] there is no switch at all,
151///   so an account that wants a freeze-only actor must use RBAC.
152///
153/// The same shape generalizes to any "can stop, cannot start" authority: give the cancelling or
154/// pausing procedure its own role and keep the resuming procedure on a separate one.
155///
156/// Storage layout:
157/// - Value slot: `[authority, is_frozen, 0, 0]`.
158/// - Map slot (only under RBAC): `procedure_root` → `[role_symbol, 0, 0, 0]`.
159#[repr(u8)]
160#[derive(Debug, Clone, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum Authority {
163    /// Authority is the account's auth component.
164    AuthControlled = AUTH_CONTROLLED,
165    /// Authority is the [`Ownable2Step`][crate::account::access::Ownable2Step] owner.
166    OwnerControlled = OWNER_CONTROLLED,
167    /// Authority is membership in an RBAC role, resolved per gated procedure.
168    ///
169    /// `procedure_roles` maps a gated procedure's [`AccountProcedureRoot`] to the role required to
170    /// invoke it. Requires the
171    /// [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be
172    /// installed on the account. the MASM helper calls into `rbac::assert_sender_has_role` and will
173    /// fail to link otherwise.
174    ///
175    /// No procedure writes this map: it is populated at deployment, and the raw
176    /// [`AccountComponent::new`] route checks only the slot count. The MASM helper therefore holds
177    /// each mapped role to the canonical [`RoleSymbol`] encoding when it reads one, so a value this
178    /// map accepts on-chain is exactly one [`Self::try_from_storage`] can decode off-chain.
179    RbacControlled {
180        procedure_roles: BTreeMap<AccountProcedureRoot, RoleSymbol>,
181    } = RBAC_CONTROLLED,
182}
183
184impl Authority {
185    /// The name of the component.
186    pub const NAME: &'static str = "miden::standards::access::authority";
187
188    /// Name of the owner-gated procedure that freezes the authority-gated surface.
189    const FREEZE_PROC_NAME: &'static str = "freeze";
190    /// Name of the owner-gated procedure that unfreezes the authority-gated surface.
191    const UNFREEZE_PROC_NAME: &'static str = "unfreeze";
192
193    /// Returns the [`AccountComponentCode`] of this component.
194    pub fn code() -> &'static AccountComponentCode {
195        &AUTHORITY_CODE
196    }
197
198    // PUBLIC ACCESSORS
199    // --------------------------------------------------------------------------------------------
200
201    /// Returns the procedure root of the `freeze` emergency switch.
202    ///
203    /// Under [`Authority::OwnerControlled`] this is gated on the owner. Under
204    /// [`Authority::RbacControlled`] it may be assigned its own role via the role map (e.g.
205    /// `FREEZER`); when unmapped it falls back to the `ADMIN` role. Unlike ordinary gated
206    /// procedures it bypasses the frozen flag so it can always be toggled.
207    pub fn freeze_root() -> AccountProcedureRoot {
208        *AUTHORITY_FREEZE
209    }
210
211    /// Returns the procedure root of the `unfreeze` emergency switch.
212    ///
213    /// Under [`Authority::OwnerControlled`] this is gated on the owner. Under
214    /// [`Authority::RbacControlled`] it may be assigned its own role via the role map (e.g.
215    /// `UNFREEZER`); when unmapped it falls back to the `ADMIN` role. Unlike ordinary gated
216    /// procedures it bypasses the frozen flag so it can always be toggled.
217    pub fn unfreeze_root() -> AccountProcedureRoot {
218        *AUTHORITY_UNFREEZE
219    }
220
221    /// Returns the [`StorageSlotName`] holding the authority configuration.
222    pub fn authority_slot() -> &'static StorageSlotName {
223        &AUTHORITY_SLOT_NAME
224    }
225
226    /// Returns the [`StorageSlotName`] holding the per-procedure role map (RBAC only).
227    pub fn procedure_roles_slot() -> &'static StorageSlotName {
228        &AUTHORITY_PROCEDURE_ROLES_SLOT_NAME
229    }
230
231    /// Reads the authority configuration from account storage.
232    pub fn try_from_storage(storage: &AccountStorage) -> Result<Self, AuthorityError> {
233        let word = Self::read_config_word(storage)?;
234
235        let discriminant: u8 = word[0]
236            .as_canonical_u64()
237            .try_into()
238            .map_err(|_| AuthorityError::InvalidAuthority(word[0].as_canonical_u64()))?;
239
240        match discriminant {
241            AUTH_CONTROLLED => Ok(Self::AuthControlled),
242            OWNER_CONTROLLED => Ok(Self::OwnerControlled),
243            RBAC_CONTROLLED => {
244                let procedure_roles = Self::read_roles_from_storage(storage)?;
245                Ok(Self::RbacControlled { procedure_roles })
246            },
247            other => Err(AuthorityError::InvalidAuthority(other.into())),
248        }
249    }
250
251    /// Reads the `is_frozen` emergency-switch flag from account storage.
252    ///
253    /// Returns `true` if the account's authority-gated surface is currently frozen (every
254    /// procedure that calls `assert_authorized` panics until it is unfrozen).
255    pub fn try_read_frozen(storage: &AccountStorage) -> Result<bool, AuthorityError> {
256        let word = Self::read_config_word(storage)?;
257
258        Ok(word[1] != Felt::ZERO)
259    }
260
261    /// Returns the [`AccountComponentMetadata`] for this configuration.
262    pub fn component_metadata(&self) -> AccountComponentMetadata {
263        let mut slots = vec![(
264            AUTHORITY_SLOT_NAME.clone(),
265            StorageSlotSchema::value(
266                "Authority configuration",
267                [
268                    FeltSchema::u8("authority"),
269                    FeltSchema::u8("is_frozen"),
270                    FeltSchema::new_void(),
271                    FeltSchema::new_void(),
272                ],
273            ),
274        )];
275
276        if matches!(self, Authority::RbacControlled { .. }) {
277            slots.push((
278                AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.clone(),
279                StorageSlotSchema::map(
280                    "Per-procedure role assignment (procedure root -> role symbol)",
281                    SchemaType::native_word(),
282                    SchemaType::role_symbol(),
283                ),
284            ));
285        }
286
287        let storage_schema = StorageSchema::new(slots).expect("storage schema should be valid");
288
289        AccountComponentMetadata::new(Self::NAME)
290            .with_description(
291                "Account-wide authority shared by procedures that gate state-mutating \
292                 operations behind auth-only, owner-based, or RBAC role-based checks",
293            )
294            .with_storage_schema(storage_schema)
295    }
296
297    // PRIVATE HELPERS
298    // --------------------------------------------------------------------------------------------
299
300    /// Returns the discriminant byte written to `word[0]` of the authority slot.
301    fn as_u8(&self) -> u8 {
302        match self {
303            Authority::AuthControlled => AUTH_CONTROLLED,
304            Authority::OwnerControlled => OWNER_CONTROLLED,
305            Authority::RbacControlled { .. } => RBAC_CONTROLLED,
306        }
307    }
308
309    /// Encodes the authority configuration value slot word: `[authority, is_frozen, 0, 0]`.
310    fn to_word(&self) -> Word {
311        Word::new([Felt::from(self.as_u8()), Felt::ZERO, Felt::ZERO, Felt::ZERO])
312    }
313
314    /// Reads and validates the authority value-slot word `[authority, is_frozen, 0, 0]`.
315    ///
316    /// Enforces the canonical encoding on read: the reserved felts `word[2]` and `word[3]` must be
317    /// zero, and `is_frozen` (`word[1]`) must be a boolean (`0` or `1`) - the exact form the write
318    /// path (`to_word` plus the MASM freeze/unfreeze switch) always produces.
319    fn read_config_word(storage: &AccountStorage) -> Result<Word, AuthorityError> {
320        let word = storage
321            .get_item(Self::authority_slot())
322            .map_err(AuthorityError::MissingStorageSlot)?;
323
324        if word[2] != Felt::ZERO || word[3] != Felt::ZERO || word[1].as_canonical_u64() > 1 {
325            return Err(AuthorityError::NonCanonicalConfig);
326        }
327
328        Ok(word)
329    }
330
331    /// Reconstructs the per-procedure role map from the procedure-roles storage slot.
332    fn read_roles_from_storage(
333        storage: &AccountStorage,
334    ) -> Result<BTreeMap<AccountProcedureRoot, RoleSymbol>, AuthorityError> {
335        let slot = storage
336            .slots()
337            .iter()
338            .find(|slot| slot.name().id() == AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.id())
339            .ok_or(AuthorityError::MissingProcedureRolesSlot)?;
340
341        let StorageSlotContent::Map(map) = slot.content() else {
342            return Err(AuthorityError::MissingProcedureRolesSlot);
343        };
344
345        let mut roles = BTreeMap::new();
346        for (key, value) in map.entries() {
347            // Enforce the canonical encoding on read: the reserved felts must be zero.
348            if value[1..4].iter().any(|v| *v != Felt::ZERO) {
349                return Err(AuthorityError::NonCanonicalConfig);
350            }
351            let proc_root = AccountProcedureRoot::from_raw(key.as_word());
352            let role = RoleSymbol::try_from(value[0]).map_err(AuthorityError::InvalidRoleSymbol)?;
353            roles.insert(proc_root, role);
354        }
355
356        Ok(roles)
357    }
358}
359
360// TRAIT IMPLEMENTATIONS
361// ================================================================================================
362
363impl From<Authority> for AccountComponent {
364    fn from(value: Authority) -> Self {
365        let metadata = value.component_metadata();
366
367        let mut slots = vec![StorageSlot::with_value(AUTHORITY_SLOT_NAME.clone(), value.to_word())];
368
369        if let Authority::RbacControlled { procedure_roles } = value {
370            let entries = procedure_roles.into_iter().map(|(proc_root, role)| {
371                (StorageMapKey::new(proc_root.as_word()), role_value_word(&role))
372            });
373            slots.push(StorageSlot::with_map(
374                AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.clone(),
375                StorageMap::with_entries(entries)
376                    .expect("authority procedure-roles map should be valid"),
377            ));
378        }
379
380        AccountComponent::new(Authority::code().clone(), slots, metadata).expect(
381            "authority component should satisfy the requirements of a valid account component",
382        )
383    }
384}
385
386/// Encodes a role symbol as a map value word: `[role_symbol, 0, 0, 0]`.
387fn role_value_word(role: &RoleSymbol) -> Word {
388    Word::new([role.into(), Felt::ZERO, Felt::ZERO, Felt::ZERO])
389}
390
391// AUTHORITY ERROR
392// ================================================================================================
393
394/// Errors raised when reading or parsing an [`Authority`] from storage.
395#[derive(Debug, Error)]
396pub enum AuthorityError {
397    #[error("invalid authority value: {0}")]
398    InvalidAuthority(u64),
399    #[error("authority configuration word is not in canonical form")]
400    NonCanonicalConfig,
401    #[error("invalid role symbol in authority storage")]
402    InvalidRoleSymbol(#[source] RoleSymbolError),
403    #[error("failed to read authority slot from storage")]
404    MissingStorageSlot(#[source] AccountError),
405    #[error("authority procedure-roles slot is missing or not a map")]
406    MissingProcedureRolesSlot,
407}
408
409#[cfg(test)]
410mod tests {
411    use assert_matches::assert_matches;
412
413    use super::*;
414
415    /// Procedure-root key of the single entry inserted by [`rbac_storage_with_role_value`].
416    const ROLE_KEY_WORD: [u32; 4] = [1, 2, 3, 4];
417
418    /// Builds account storage whose authority value slot holds `word`.
419    fn storage_with_config(word: Word) -> AccountStorage {
420        let slot = StorageSlot::with_value(Authority::authority_slot().clone(), word);
421        AccountStorage::new(vec![slot]).expect("storage should be valid")
422    }
423
424    /// Builds RBAC account storage whose procedure-roles map holds a single entry, keyed by
425    /// [`ROLE_KEY_WORD`], with `role_value` as its value word.
426    fn rbac_storage_with_role_value(role_value: Word) -> AccountStorage {
427        let config = StorageSlot::with_value(
428            Authority::authority_slot().clone(),
429            Word::from([u32::from(RBAC_CONTROLLED), 0, 0, 0]),
430        );
431        let key = StorageMapKey::new(Word::from(ROLE_KEY_WORD));
432        let map = StorageMap::with_entries([(key, role_value)]).expect("map should be valid");
433        let roles = StorageSlot::with_map(Authority::procedure_roles_slot().clone(), map);
434        AccountStorage::new(vec![config, roles]).expect("storage should be valid")
435    }
436
437    #[test]
438    fn canonical_config_is_accepted() {
439        // AuthControlled, not frozen.
440        let storage = storage_with_config(Word::from([u32::from(AUTH_CONTROLLED), 0, 0, 0]));
441        assert_eq!(Authority::try_from_storage(&storage).unwrap(), Authority::AuthControlled);
442        assert!(!Authority::try_read_frozen(&storage).unwrap());
443
444        // OwnerControlled, frozen.
445        let storage = storage_with_config(Word::from([u32::from(OWNER_CONTROLLED), 1, 0, 0]));
446        assert_eq!(Authority::try_from_storage(&storage).unwrap(), Authority::OwnerControlled);
447        assert!(Authority::try_read_frozen(&storage).unwrap());
448    }
449
450    #[test]
451    fn non_zero_reserved_felt_is_rejected() {
452        // word[3] carries unexpected trailing data.
453        let storage = storage_with_config(Word::from([u32::from(OWNER_CONTROLLED), 0, 0, 7]));
454        assert!(matches!(
455            Authority::try_from_storage(&storage),
456            Err(AuthorityError::NonCanonicalConfig)
457        ));
458        assert!(matches!(
459            Authority::try_read_frozen(&storage),
460            Err(AuthorityError::NonCanonicalConfig)
461        ));
462
463        // word[2] carries unexpected trailing data.
464        let storage = storage_with_config(Word::from([u32::from(OWNER_CONTROLLED), 0, 5, 0]));
465        assert!(matches!(
466            Authority::try_from_storage(&storage),
467            Err(AuthorityError::NonCanonicalConfig)
468        ));
469    }
470
471    #[test]
472    fn non_boolean_frozen_flag_is_rejected() {
473        // is_frozen (word[1]) must be 0 or 1; 2 is non-canonical.
474        let storage = storage_with_config(Word::from([u32::from(AUTH_CONTROLLED), 2, 0, 0]));
475        assert!(matches!(
476            Authority::try_from_storage(&storage),
477            Err(AuthorityError::NonCanonicalConfig)
478        ));
479        assert!(matches!(
480            Authority::try_read_frozen(&storage),
481            Err(AuthorityError::NonCanonicalConfig)
482        ));
483    }
484
485    #[test]
486    fn non_zero_reserved_felt_in_role_value_is_rejected() {
487        let role = RoleSymbol::new("ADMIN").unwrap();
488        let role_felt: Felt = (&role).into();
489        let expected_root = AccountProcedureRoot::from_raw(Word::from(ROLE_KEY_WORD));
490
491        // A canonical role value word `[role, 0, 0, 0]` is accepted and parses the configured role.
492        let storage = rbac_storage_with_role_value(Word::new([
493            role_felt,
494            Felt::ZERO,
495            Felt::ZERO,
496            Felt::ZERO,
497        ]));
498        assert_matches!(
499            Authority::try_from_storage(&storage),
500            Ok(Authority::RbacControlled { procedure_roles })
501                if procedure_roles.get(&expected_root) == Some(&role)
502        );
503
504        // A non-zero reserved felt in the role value word carries unexpected trailing data.
505        let storage = rbac_storage_with_role_value(Word::new([
506            role_felt,
507            Felt::ZERO,
508            Felt::from(9u8),
509            Felt::ZERO,
510        ]));
511        assert_matches!(
512            Authority::try_from_storage(&storage),
513            Err(AuthorityError::NonCanonicalConfig)
514        );
515    }
516
517    /// A role that has been removed leaves no map entry behind, so the procedure is simply
518    /// unmapped and falls back to `ADMIN`, which is what the MASM check does as well.
519    #[test]
520    fn removed_role_leaves_the_procedure_unmapped() {
521        let storage = rbac_storage_with_role_value(Word::empty());
522        let removed_root = AccountProcedureRoot::from_raw(Word::from(ROLE_KEY_WORD));
523
524        assert_matches!(
525            Authority::try_from_storage(&storage),
526            Ok(Authority::RbacControlled { procedure_roles })
527                if !procedure_roles.contains_key(&removed_root)
528        );
529    }
530}