miden_standards/account/access/rbac.rs
1use alloc::vec;
2
3use miden_protocol::account::component::{
4 AccountComponentCode,
5 AccountComponentMetadata,
6 SchemaType,
7 StorageSchema,
8 StorageSlotSchema,
9};
10use miden_protocol::account::{
11 AccountComponent,
12 AccountComponentName,
13 StorageMap,
14 StorageSlot,
15 StorageSlotName,
16};
17use miden_protocol::utils::sync::LazyLock;
18
19use crate::account::account_component_code;
20
21account_component_code!(RBAC_CODE, "access/rbac.masl");
22
23static ROLE_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
24 StorageSlotName::new("miden::standards::access::rbac::role_config")
25 .expect("storage slot name should be valid")
26});
27static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
28 StorageSlotName::new("miden::standards::access::rbac::role_membership")
29 .expect("storage slot name should be valid")
30});
31
32/// Role-based access control (RBAC) for account components.
33///
34/// RBAC provides fine-grained access control on top of [`Ownable2Step`]. Instead of having
35/// one account holding every privilege, privileges are split into named roles (for example
36/// `MINTER`, `BURNER`, `PAUSER`), and each procedure is guarded against the caller's role
37/// membership. It allows role assignment with domain isolation to minimize the scope of
38/// damage from a compromised role.
39///
40/// ## Relation to [`Ownable2Step`]
41///
42/// RBAC is a superset of [`Ownable2Step`] and depends on it: the top-level authority is
43/// the [`Ownable2Step`] owner of the account. Build the pair via
44/// [`AccessControl::Rbac`][crate::account::access::AccessControl::Rbac] passed to
45/// [`AccountBuilder::with_components`][miden_protocol::account::AccountBuilder::with_components].
46/// This avoids duplicated state, duplicated 2-step transfer logic, and duplicated notes
47/// for owner transfers. If you only need single-account control, use [`Ownable2Step`]
48/// alone.
49///
50/// [`Ownable2Step`]: crate::account::access::Ownable2Step
51///
52/// ## Owner management
53///
54/// The owner can grant and revoke any role, configure the delegated admin of any role via
55/// `set_role_admin`, and transfer or renounce its own position. Owner transfer and
56/// renouncement go through [`Ownable2Step`] (`transfer_ownership`, `accept_ownership`,
57/// `renounce_ownership`).
58///
59/// ## Role hierarchy
60///
61/// Every role may optionally have a delegated admin role. Accounts holding a role's admin
62/// role are authorized to grant and revoke that role without going through the owner.
63/// For example, accounts holding `MINTER_ADMIN` can manage the `MINTER` role but have no
64/// authority over `BURNER` or `PAUSER`. This lets responsibilities be distributed so that
65/// compromise of one domain does not spill into the others.
66///
67/// Combined with owner renouncement, this supports a fully decentralized configuration:
68/// once every role has its own admin role populated, the owner can renounce and the
69/// system continues to operate with each role managed only by its designated admin role.
70///
71/// The delegated admin of a role can itself be any role, including one that it admins.
72/// Circular relationships are possible but should be designed with care, since each role
73/// can then revoke the other.
74///
75/// ## Role semantics
76///
77/// A role is considered to exist when it has at least one member. Granting the first
78/// member creates the role; revoking the last member removes it. As a consequence,
79/// `set_role_admin(A, B)` stores the admin relationship in storage but does not make role
80/// `A` exist until a member is granted. Once the last member of `A` is revoked,
81/// `get_role_member_count(A)` returns `0`, though the admin configuration is retained and
82/// will apply the next time a member is granted.
83///
84/// ## Membership lookup
85///
86/// `has_role` procedure is the primary guard used by procedures that assert the caller's
87/// role membership. `get_role_member_count` returns the number of accounts holding a role.
88///
89/// ## Role symbol format
90///
91/// A [`RoleSymbol`] encodes up to 12 uppercase ASCII characters with underscores into a
92/// single field element using the same packing as the token symbol type. Examples:
93/// `MINTER`, `MINTER_ADMIN`, `PAUSER`. The zero field element is reserved and cannot be
94/// used as a role symbol; attempting to do so panics with `ERR_ROLE_SYMBOL_ZERO`.
95///
96/// ## Usage
97///
98/// Guarding a procedure in MASM so that only members of `MINTER` can call it:
99///
100/// ```text
101/// pub proc mint
102/// push.MINTER_ROLE_SYMBOL
103/// exec.::miden::standards::access::rbac::assert_sender_has_role
104/// # add mint logic
105/// end
106/// ```
107///
108/// [`RoleSymbol`]: miden_protocol::account::RoleSymbol
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct RoleBasedAccessControl;
111
112impl RoleBasedAccessControl {
113 pub const NAME: &'static str = "miden::standards::components::access::rbac";
114
115 /// Returns the canonical [`AccountComponentName`] of this component.
116 pub const fn name() -> AccountComponentName {
117 AccountComponentName::from_static_str(Self::NAME)
118 }
119
120 /// Returns the [`AccountComponentCode`] of this component.
121 pub fn code() -> &'static AccountComponentCode {
122 &RBAC_CODE
123 }
124
125 /// Returns an empty RBAC component. Roles are populated at runtime via the
126 /// `grant_role`, `set_role_admin`, etc. procedures exposed by the component.
127 pub fn empty() -> Self {
128 Self
129 }
130
131 /// Returns the storage slot name for the per-role config map.
132 pub fn role_config_slot() -> &'static StorageSlotName {
133 &ROLE_CONFIG_SLOT_NAME
134 }
135
136 /// Returns the storage slot name for the per-role membership map.
137 pub fn role_membership_slot() -> &'static StorageSlotName {
138 &ROLE_MEMBERSHIP_SLOT_NAME
139 }
140
141 /// Returns the schema entry for the per-role config map.
142 pub fn role_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
143 (
144 Self::role_config_slot().clone(),
145 StorageSlotSchema::map(
146 "Per-role RBAC configuration (member count and delegated admin role)",
147 SchemaType::role_symbol(),
148 SchemaType::native_word(),
149 ),
150 )
151 }
152
153 /// Returns the schema entry for the per-role membership map.
154 pub fn role_membership_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
155 (
156 Self::role_membership_slot().clone(),
157 StorageSlotSchema::map(
158 "Role membership flag indexed by role symbol and account ID",
159 SchemaType::native_word(),
160 SchemaType::native_word(),
161 ),
162 )
163 }
164
165 /// Returns the [`AccountComponentMetadata`] describing this component.
166 pub fn component_metadata() -> AccountComponentMetadata {
167 let storage_schema = StorageSchema::new(vec![
168 Self::role_config_slot_schema(),
169 Self::role_membership_slot_schema(),
170 ])
171 .expect("storage schema should be valid");
172
173 AccountComponentMetadata::new(Self::NAME)
174 .with_description("Role-based access control component")
175 .with_storage_schema(storage_schema)
176 }
177}
178
179impl From<RoleBasedAccessControl> for AccountComponent {
180 fn from(_rbac: RoleBasedAccessControl) -> Self {
181 let role_config_slot = StorageSlot::with_map(
182 RoleBasedAccessControl::role_config_slot().clone(),
183 StorageMap::with_entries(vec![]).expect("empty role config map should be valid"),
184 );
185 let role_membership_slot = StorageSlot::with_map(
186 RoleBasedAccessControl::role_membership_slot().clone(),
187 StorageMap::with_entries(vec![]).expect("empty role membership map should be valid"),
188 );
189
190 AccountComponent::new(
191 RoleBasedAccessControl::code().clone(),
192 vec![role_config_slot, role_membership_slot],
193 RoleBasedAccessControl::component_metadata(),
194 )
195 .expect("RBAC component should satisfy the requirements of a valid account component")
196 }
197}