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}