stellar_access/access_control/mod.rs
1//! Access control module for Soroban contracts
2//!
3//! This module provides functionality to manage role-based access control in
4//! Soroban contracts.
5//!
6//! # Usage
7//!
8//! There is a single overarching admin, and the admin has enough privileges to
9//! call any function given in the [`AccessControl`] trait.
10//!
11//! This `admin` must be set in the constructor of the contract. Else, none of
12//! the methods exposed by this module will work. You can follow the
13//! `nft-access-control` example.
14//!
15//! ## Admin Transfers
16//!
17//! Transferring the top-level admin is a critical action, and as such, it is
18//! implemented as a **two-step process** to prevent accidental or malicious
19//! takeovers:
20//!
21//! 1. The current admin **initiates** the transfer by specifying the
22//! `new_admin` and a `live_until_ledger`, which defines the expiration time
23//! for the offer.
24//! 2. The designated `new_admin` must **explicitly accept** the transfer to
25//! complete it.
26//!
27//! Until the transfer is accepted, the original admin retains full control, and
28//! the transfer can be overridden or canceled by initiating a new one or using
29//! a `live_until_ledger` of `0`.
30//!
31//! This handshake mechanism ensures that the recipient is aware and willing to
32//! assume responsibility, providing a robust safeguard in governance-sensitive
33//! deployments.
34//!
35//! ## Role Hierarchy
36//!
37//! Each role can have an "admin role" specified for it. For example, if you
38//! create two roles: `minter` and `minter_admin`, you can assign
39//! `minter_admin` as the admin role for the `minter` role. This will allow
40//! to accounts with `minter_admin` role to grant/revoke the `minter` role
41//! to other accounts.
42//!
43//! One can create as many roles as they want, and create a chain of command
44//! structure if they want to go with this approach.
45//!
46//! If you need even more granular control over which roles can do what, you can
47//! introduce your own business logic, and annotate it with our macro:
48//!
49//! ```rust
50//! #[has_role(caller, "minter_admin")]
51//! pub fn custom_sensitive_logic(e: &Env, caller: Address) {
52//! ...
53//! }
54//! ```
55//!
56//! ### ⚠️ Warning: Circular Admin Relationships
57//!
58//! When designing your role hierarchy, be careful to avoid creating circular
59//! admin relationships. For example, it's possible but not recommended to
60//! assign `MINT_ADMIN` as the admin of `MINT_ROLE` while also making
61//! `MINT_ROLE` the admin of `MINT_ADMIN`. Such circular relationships can lead
62//! to unintended consequences, including:
63//!
64//! - Race conditions where each role can revoke the other
65//! - Potential security vulnerabilities in role management
66//! - Confusing governance structures that are difficult to reason about
67//!
68//! ## Enumeration of Roles
69//!
70//! In this access control system, roles don't exist as standalone entities.
71//! Instead, the system stores account-role pairs in storage with additional
72//! enumeration logic:
73//!
74//! - When a role is granted to an account, the account-role pair is stored and
75//! added to enumeration storage (RoleAccountsCount and RoleAccounts).
76//! - When a role is revoked from an account, the account-role pair is removed
77//! from storage and from enumeration.
78//! - If all accounts are removed from a role, the helper storage items for that
79//! role become empty or 0, but the entries themselves remain.
80//!
81//! This means that the question of whether a role can "exist" with 0 accounts
82//! is technically invalid, because roles only exist through their relationships
83//! with accounts. When checking if a role has any accounts via
84//! `get_role_member_count`, it returns 0 in two cases:
85//!
86//! 1. When accounts were assigned to a role but later all were removed.
87//! 2. When a role never existed in the first place.
88
89mod storage;
90
91mod test;
92
93use soroban_sdk::{contracterror, Address, Env, Symbol};
94
95pub use crate::access_control::storage::{
96 accept_admin_transfer, add_to_role_enumeration, enforce_admin_auth,
97 ensure_if_admin_or_admin_role, ensure_role, get_admin, get_role_admin, get_role_member,
98 get_role_member_count, grant_role, grant_role_no_auth, has_role, remove_from_role_enumeration,
99 remove_role_accounts_count_no_auth, remove_role_admin_no_auth, renounce_admin, renounce_role,
100 revoke_role, revoke_role_no_auth, set_admin, set_role_admin, set_role_admin_no_auth,
101 transfer_admin_role, AccessControlStorageKey,
102};
103
104pub trait AccessControl {
105 /// Returns `Some(index)` if the account has the specified role,
106 /// where `index` is the position of the account for that role,
107 /// and can be used to query [`AccessControl::get_role_member()`].
108 /// Returns `None` if the account does not have the specified role.
109 ///
110 /// # Arguments
111 ///
112 /// * `e` - Access to Soroban environment.
113 /// * `account` - The account to check.
114 /// * `role` - The role to check for.
115 fn has_role(e: &Env, account: Address, role: Symbol) -> Option<u32>;
116
117 /// Returns the total number of accounts that have the specified role.
118 /// If the role does not exist, returns 0.
119 ///
120 /// # Arguments
121 ///
122 /// * `e` - Access to Soroban environment.
123 /// * `role` - The role to get the count for.
124 fn get_role_member_count(e: &Env, role: Symbol) -> u32;
125
126 /// Returns the account at the specified index for a given role.
127 ///
128 /// We do not provide a function to get all the members of a role,
129 /// since that would be unbounded. If you need to enumerate all the
130 /// members of a role, you can use
131 /// [`AccessControl::get_role_member_count()`] to get the total number
132 /// of members and then use [`AccessControl::get_role_member()`] to get
133 /// each member one by one.
134 ///
135 /// # Arguments
136 ///
137 /// * `e` - Access to Soroban environment.
138 /// * `role` - The role to query.
139 /// * `index` - The index of the account to retrieve.
140 ///
141 /// # Errors
142 ///
143 /// * [`AccessControlError::IndexOutOfBounds`] - If the index is out of
144 /// bounds for the role's member list.
145 fn get_role_member(e: &Env, role: Symbol, index: u32) -> Address;
146
147 /// Returns the admin role for a specific role.
148 /// If no admin role is explicitly set, returns `None`.
149 ///
150 /// # Arguments
151 ///
152 /// * `e` - Access to Soroban environment.
153 /// * `role` - The role to query the admin role for.
154 fn get_role_admin(e: &Env, role: Symbol) -> Option<Symbol>;
155
156 /// Returns the admin account.
157 ///
158 /// # Arguments
159 ///
160 /// * `e` - Access to Soroban environment.
161 fn get_admin(e: &Env) -> Option<Address>;
162
163 /// Grants a role to an account.
164 ///
165 /// # Arguments
166 ///
167 /// * `e` - Access to Soroban environment.
168 /// * `caller` - The address of the caller, must be the admin or have the
169 /// `RoleAdmin` for the `role`.
170 /// * `account` - The account to grant the role to.
171 /// * `role` - The role to grant.
172 ///
173 /// # Errors
174 ///
175 /// * [`AccessControlError::Unauthorized`] - If the caller does not have
176 /// enough privileges.
177 ///
178 /// # Events
179 ///
180 /// * topics - `["role_granted", role: Symbol, account: Address]`
181 /// * data - `[caller: Address]`
182 fn grant_role(e: &Env, caller: Address, account: Address, role: Symbol);
183
184 /// Revokes a role from an account.
185 /// To revoke your own role, please use [`AccessControl::renounce_role()`]
186 /// instead.
187 ///
188 /// # Arguments
189 ///
190 /// * `e` - Access to Soroban environment.
191 /// * `caller` - The address of the caller, must be the admin or has the
192 /// `RoleAdmin` for the `role`.
193 /// * `account` - The account to revoke the role from.
194 /// * `role` - The role to revoke.
195 ///
196 /// # Errors
197 ///
198 /// * [`AccessControlError::Unauthorized`] - If the `caller` does not have
199 /// enough privileges.
200 /// * [`AccessControlError::RoleNotHeld`] - If the `account` doesn't have
201 /// the role.
202 /// * [`AccessControlError::RoleIsEmpty`] - If the role has no members.
203 ///
204 /// # Events
205 ///
206 /// * topics - `["role_revoked", role: Symbol, account: Address]`
207 /// * data - `[caller: Address]`
208 fn revoke_role(e: &Env, caller: Address, account: Address, role: Symbol);
209
210 /// Allows an account to renounce a role assigned to itself.
211 /// Users can only renounce roles for their own account.
212 ///
213 /// # Arguments
214 ///
215 /// * `e` - Access to Soroban environment.
216 /// * `caller` - The address of the caller, must be the account that has the
217 /// role.
218 /// * `role` - The role to renounce.
219 ///
220 /// # Errors
221 ///
222 /// * [`AccessControlError::RoleNotHeld`] - If the `caller` doesn't have the
223 /// role.
224 /// * [`AccessControlError::RoleIsEmpty`] - If the role has no members.
225 ///
226 /// # Events
227 ///
228 /// * topics - `["role_revoked", role: Symbol, account: Address]`
229 /// * data - `[caller: Address]`
230 fn renounce_role(e: &Env, caller: Address, role: Symbol);
231
232 /// Initiates the admin role transfer.
233 /// Admin privileges for the current admin are not revoked until the
234 /// recipient accepts the transfer.
235 /// Overrides the previous pending transfer if there is one.
236 ///
237 /// # Arguments
238 ///
239 /// * `e` - Access to Soroban environment.
240 /// * `new_admin` - The account to transfer the admin privileges to.
241 /// * `live_until_ledger` - The ledger number at which the pending transfer
242 /// expires. If `live_until_ledger` is `0`, the pending transfer is
243 /// cancelled. `live_until_ledger` argument is implicitly bounded by the
244 /// maximum allowed TTL extension for a temporary storage entry and
245 /// specifying a higher value will cause the code to panic.
246 ///
247 /// # Errors
248 ///
249 /// * [`crate::role_transfer::RoleTransferError::NoPendingTransfer`] - If
250 /// trying to cancel a transfer that doesn't exist.
251 /// * [`crate::role_transfer::RoleTransferError::InvalidLiveUntilLedger`] -
252 /// If the specified ledger is in the past.
253 /// * [`crate::role_transfer::RoleTransferError::InvalidPendingAccount`] -
254 /// If the specified pending account is not the same as the provided `new`
255 /// address.
256 /// * [`AccessControlError::AdminNotSet`] - If admin account is not set.
257 ///
258 /// # Events
259 ///
260 /// * topics - `["admin_transfer_initiated", current_admin: Address]`
261 /// * data - `[new_admin: Address, live_until_ledger: u32]`
262 ///
263 /// # Notes
264 ///
265 /// * Authorization for the current admin is required.
266 fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32);
267
268 /// Completes the 2-step admin transfer.
269 ///
270 /// # Arguments
271 ///
272 /// * `e` - Access to Soroban environment.
273 ///
274 /// # Events
275 ///
276 /// * topics - `["admin_transfer_completed", new_admin: Address]`
277 /// * data - `[previous_admin: Address]`
278 ///
279 /// # Errors
280 ///
281 /// * [`crate::role_transfer::RoleTransferError::NoPendingTransfer`] - If
282 /// there is no pending transfer to accept.
283 /// * [`AccessControlError::AdminNotSet`] - If admin account is not set.
284 fn accept_admin_transfer(e: &Env);
285
286 /// Sets `admin_role` as the admin role of `role`.
287 ///
288 /// # Arguments
289 ///
290 /// * `e` - Access to Soroban environment.
291 /// * `role` - The role to set the admin for.
292 /// * `admin_role` - The new admin role.
293 ///
294 /// # Events
295 ///
296 /// * topics - `["role_admin_changed", role: Symbol]`
297 /// * data - `[previous_admin_role: Symbol, new_admin_role: Symbol]`
298 ///
299 /// # Errors
300 ///
301 /// * [`AccessControlError::AdminNotSet`] - If admin account is not set.
302 ///
303 /// # Notes
304 ///
305 /// * Authorization for the current admin is required.
306 fn set_role_admin(e: &Env, role: Symbol, admin_role: Symbol);
307
308 /// Allows the current admin to renounce their role, making the contract
309 /// permanently admin-less. This is useful for decentralization purposes
310 /// or when the admin role is no longer needed. Once the admin is
311 /// renounced, it cannot be reinstated.
312 ///
313 /// # Arguments
314 ///
315 /// * `e` - Access to Soroban environment.
316 ///
317 /// # Errors
318 ///
319 /// * [`AccessControlError::AdminNotSet`] - If no admin account is set.
320 ///
321 /// # Events
322 ///
323 /// * topics - `["admin_renounced", admin: Address]`
324 /// * data - `[]`
325 ///
326 /// # Notes
327 ///
328 /// * Authorization for the current admin is required.
329 fn renounce_admin(e: &Env);
330}
331
332// ################## ERRORS ##################
333
334#[contracterror]
335#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
336#[repr(u32)]
337pub enum AccessControlError {
338 Unauthorized = 1210,
339 AdminNotSet = 1211,
340 IndexOutOfBounds = 1212,
341 AdminRoleNotFound = 1213,
342 RoleCountIsNotZero = 1214,
343 RoleNotFound = 1215,
344 AdminAlreadySet = 1216,
345 RoleNotHeld = 1217,
346 RoleIsEmpty = 1218,
347}
348
349// ################## CONSTANTS ##################
350
351const DAY_IN_LEDGERS: u32 = 17280;
352pub const ROLE_EXTEND_AMOUNT: u32 = 90 * DAY_IN_LEDGERS;
353pub const ROLE_TTL_THRESHOLD: u32 = ROLE_EXTEND_AMOUNT - DAY_IN_LEDGERS;
354
355// ################## EVENTS ##################
356
357/// Emits an event when a role is granted to an account.
358///
359/// # Arguments
360///
361/// * `e` - Access to Soroban environment.
362/// * `role` - The role that was granted.
363/// * `account` - The account that received the role.
364/// * `caller` - The account that granted the role.
365///
366/// # Events
367///
368/// * topics - `["role_granted", role: Symbol, account: Address]`
369/// * data - `[caller: Address]`
370pub fn emit_role_granted(e: &Env, role: &Symbol, account: &Address, caller: &Address) {
371 let topics = (Symbol::new(e, "role_granted"), role, account);
372 e.events().publish(topics, caller);
373}
374
375/// Emits an event when a role is revoked from an account.
376///
377/// # Arguments
378///
379/// * `e` - Access to Soroban environment.
380/// * `role` - The role that was revoked.
381/// * `account` - The account that lost the role.
382/// * `caller` - The account that revoked the role (either the admin or the
383/// account itself).
384///
385/// # Events
386///
387/// * topics - `["role_revoked", role: Symbol, account: Address]`
388/// * data - `[caller: Address]`
389pub fn emit_role_revoked(e: &Env, role: &Symbol, account: &Address, caller: &Address) {
390 let topics = (Symbol::new(e, "role_revoked"), role, account);
391 e.events().publish(topics, caller);
392}
393
394/// Emits an event when the admin role for a role changes.
395///
396/// # Arguments
397///
398/// * `e` - Access to Soroban environment.
399/// * `role` - The role whose admin is changing.
400/// * `previous_admin_role` - The previous admin role.
401/// * `new_admin_role` - The new admin role.
402///
403/// # Events
404///
405/// * topics - `["role_admin_changed", role: Symbol]`
406/// * data - `[previous_admin_role: Symbol, new_admin_role: Symbol]`
407pub fn emit_role_admin_changed(
408 e: &Env,
409 role: &Symbol,
410 previous_admin_role: &Symbol,
411 new_admin_role: &Symbol,
412) {
413 let topics = (Symbol::new(e, "role_admin_changed"), role);
414 e.events().publish(topics, (previous_admin_role, new_admin_role));
415}
416
417/// Emits an event when an admin transfer is initiated.
418///
419/// # Arguments
420///
421/// * `e` - Access to Soroban environment.
422/// * `current_admin` - The current admin initiating the transfer.
423/// * `new_admin` - The proposed new admin.
424/// * `live_until_ledger` - The ledger number at which the pending transfer will
425/// expire. If this value is `0`, it means the pending transfer is cancelled.
426///
427/// # Events
428///
429/// * topics - `["admin_transfer_initiated", current_admin: Address]`
430/// * data - `[new_admin: Address, live_until_ledger: u32]`
431pub fn emit_admin_transfer_initiated(
432 e: &Env,
433 current_admin: &Address,
434 new_admin: &Address,
435 live_until_ledger: u32,
436) {
437 let topics = (Symbol::new(e, "admin_transfer_initiated"), current_admin);
438 e.events().publish(topics, (new_admin, live_until_ledger));
439}
440
441/// Emits an event when an admin transfer is completed.
442///
443/// # Arguments
444///
445/// * `e` - Access to Soroban environment.
446/// * `previous_admin` - The previous admin.
447/// * `new_admin` - The new admin who accepted the transfer.
448///
449/// # Events
450///
451/// * topics - `["admin_transfer_completed", new_admin: Address]`
452/// * data - `[previous_admin: Address]`
453pub fn emit_admin_transfer_completed(e: &Env, previous_admin: &Address, new_admin: &Address) {
454 let topics = (Symbol::new(e, "admin_transfer_completed"), new_admin);
455 e.events().publish(topics, previous_admin);
456}
457
458/// Emits an event when the admin role is renounced.
459///
460/// # Arguments
461///
462/// * `e` - Access to Soroban environment.
463/// * `admin` - The admin that renounced the role.
464///
465/// # Events
466///
467/// * topics - `["admin_renounced", admin: Address]`
468/// * data - `[]`
469pub fn emit_admin_renounced(e: &Env, admin: &Address) {
470 let topics = (Symbol::new(e, "admin_renounced"), admin);
471 e.events().publish(topics, ());
472}