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