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}