Skip to main content

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. Otherwise,
12//! none of the methods exposed by this module will work. See 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 two
38//! roles are created, `minter` and `minter_admin`, `minter_admin` can be
39//! assigned
40//! `minter_admin` as the admin role for the `minter` role. This will allow
41//! to accounts with `minter_admin` role to grant/revoke the `minter` role
42//! to other accounts.
43//!
44//! Up to 256 roles can be created simultaneously, allowing a chain-of-command
45//! structure to be established when desired.
46//!
47//! If even more granular control over role capabilities is required, custom
48//! business logic can be introduced and annotated with the provided macros.
49//! Use `#[only_role]` when the function needs both a role check **and**
50//! authorization of the address. Use `#[has_role]` only when the function
51//! already calls `require_auth()` on the address separately:
52//!
53//! ```rust
54//! #[only_role(caller, "minter_admin")]
55//! pub fn custom_sensitive_logic(e: &Env, caller: Address) {
56//!     ...
57//! }
58//! ```
59//!
60//! ### ⚠️ Warning: Circular Admin Relationships
61//!
62//! When designing the role hierarchy, care should be taken to avoid creating
63//! circular admin relationships. For example, it's possible but not recommended
64//! to assign `MINT_ADMIN` as the admin of `MINT_ROLE` while also making
65//! `MINT_ROLE` the admin of `MINT_ADMIN`. Such circular relationships can lead
66//! to unintended consequences, including:
67//!
68//! - Race conditions where each role can revoke the other
69//! - Potential security vulnerabilities in role management
70//! - Confusing governance structures that are difficult to reason about
71//!
72//! ## Enumeration of Roles
73//!
74//! In this access control system, roles don't exist as standalone entities.
75//! Instead, the system stores account-role pairs in storage with additional
76//! enumeration logic:
77//!
78//! - When a role is granted to an account, the account-role pair is stored and
79//!   added to enumeration storage (RoleAccountsCount and RoleAccounts).
80//! - When a role is revoked from an account, the account-role pair is removed
81//!   from storage and from enumeration.
82//! - If all accounts are removed from a role, the helper storage items for that
83//!   role become empty or 0, but the entries themselves remain.
84//!
85//! This means that the question of whether a role can "exist" with 0 accounts
86//! is technically invalid, because roles only exist through their relationships
87//! with accounts. When checking if a role has any accounts via
88//! `get_role_member_count`, it returns 0 in two cases:
89//!
90//! 1. When accounts were assigned to a role but later all were removed.
91//! 2. When a role never existed in the first place.
92
93mod storage;
94
95#[cfg(test)]
96mod test;
97
98use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env, Symbol, Vec};
99
100pub use crate::access_control::storage::{
101    accept_admin_transfer, add_to_role_enumeration, enforce_admin_auth,
102    ensure_if_admin_or_admin_role, ensure_role, get_admin, get_existing_roles, get_role_admin,
103    get_role_member, get_role_member_count, grant_role, grant_role_no_auth, has_role,
104    remove_from_role_enumeration, remove_role_accounts_count_no_auth, remove_role_admin_no_auth,
105    renounce_admin, renounce_role, revoke_role, revoke_role_no_auth, set_admin, set_role_admin,
106    set_role_admin_no_auth, transfer_admin_role, AccessControlStorageKey,
107};
108
109#[contracttrait]
110pub trait AccessControl {
111    /// Returns `Some(index)` if the account has the specified role,
112    /// where `index` is the position of the account for that role,
113    /// and can be used to query [`AccessControl::get_role_member()`].
114    /// Returns `None` if the account does not have the specified role.
115    ///
116    /// # Arguments
117    ///
118    /// * `e` - Access to Soroban environment.
119    /// * `account` - The account to check.
120    /// * `role` - The role to check for.
121    fn has_role(e: &Env, account: Address, role: Symbol) -> Option<u32> {
122        storage::has_role(e, &account, &role)
123    }
124
125    /// Returns a vector containing all existing roles.
126    /// Defaults to empty vector if no roles exist.
127    ///
128    /// # Arguments
129    ///
130    /// * `e` - Access to Soroban environment.
131    ///
132    /// # Notes
133    ///
134    /// This function returns all roles that currently have at least one member.
135    /// The maximum number of roles is limited by [`MAX_ROLES`].
136    fn get_existing_roles(e: &Env) -> Vec<Symbol> {
137        storage::get_existing_roles(e)
138    }
139
140    /// Returns the total number of accounts that have the specified role.
141    /// If the role does not exist, returns 0.
142    ///
143    /// # Arguments
144    ///
145    /// * `e` - Access to Soroban environment.
146    /// * `role` - The role to get the count for.
147    fn get_role_member_count(e: &Env, role: Symbol) -> u32 {
148        storage::get_role_member_count(e, &role)
149    }
150
151    /// Returns the account at the specified index for a given role.
152    ///
153    /// A function to get all members of a role is not provided because that
154    /// would be unbounded. To enumerate all members of a role, use
155    /// [`AccessControl::get_role_member_count()`] to get the total number of
156    /// members and then use [`AccessControl::get_role_member()`] to retrieve
157    /// each member one by one.
158    ///
159    /// # Arguments
160    ///
161    /// * `e` - Access to Soroban environment.
162    /// * `role` - The role to query.
163    /// * `index` - The index of the account to retrieve.
164    ///
165    /// # Errors
166    ///
167    /// * [`AccessControlError::IndexOutOfBounds`] - If the index is out of
168    ///   bounds for the role's member list.
169    fn get_role_member(e: &Env, role: Symbol, index: u32) -> Address {
170        storage::get_role_member(e, &role, index)
171    }
172
173    /// Returns the admin role for a specific role.
174    /// If no admin role is explicitly set, returns `None`.
175    ///
176    /// # Arguments
177    ///
178    /// * `e` - Access to Soroban environment.
179    /// * `role` - The role to query the admin role for.
180    fn get_role_admin(e: &Env, role: Symbol) -> Option<Symbol> {
181        storage::get_role_admin(e, &role)
182    }
183
184    /// Returns the admin account.
185    ///
186    /// # Arguments
187    ///
188    /// * `e` - Access to Soroban environment.
189    fn get_admin(e: &Env) -> Option<Address> {
190        storage::get_admin(e)
191    }
192
193    /// Grants a role to an account.
194    ///
195    /// # Arguments
196    ///
197    /// * `e` - Access to Soroban environment.
198    /// * `account` - The account to grant the role to.
199    /// * `role` - The role to grant.
200    /// * `caller` - The address of the caller, must be the admin or have the
201    ///   `RoleAdmin` for the `role`.
202    ///
203    /// # Errors
204    ///
205    /// * [`AccessControlError::Unauthorized`] - If the caller does not have
206    ///   enough privileges.
207    /// * [`AccessControlError::MaxRolesExceeded`] - If adding a new role would
208    ///   exceed the maximum allowed number of roles.
209    ///
210    /// # Events
211    ///
212    /// * topics - `["role_granted", role: Symbol, account: Address]`
213    /// * data - `[caller: Address]`
214    fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) {
215        storage::grant_role(e, &account, &role, &caller);
216    }
217
218    /// Revokes a role from an account.
219    /// To revoke the caller's own role, use
220    /// [`AccessControl::renounce_role()`] instead.
221    ///
222    /// # Arguments
223    ///
224    /// * `e` - Access to Soroban environment.
225    /// * `account` - The account to revoke the role from.
226    /// * `role` - The role to revoke.
227    /// * `caller` - The address of the caller, must be the admin or has the
228    ///   `RoleAdmin` for the `role`.
229    ///
230    /// # Errors
231    ///
232    /// * [`AccessControlError::Unauthorized`] - If the `caller` does not have
233    ///   enough privileges.
234    /// * [`AccessControlError::RoleNotHeld`] - If the `account` doesn't have
235    ///   the role.
236    /// * [`AccessControlError::RoleIsEmpty`] - If the role has no members.
237    ///
238    /// # Events
239    ///
240    /// * topics - `["role_revoked", role: Symbol, account: Address]`
241    /// * data - `[caller: Address]`
242    fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) {
243        storage::revoke_role(e, &account, &role, &caller);
244    }
245
246    /// Allows an account to renounce a role assigned to itself.
247    /// Users can only renounce roles for their own account.
248    ///
249    /// # Arguments
250    ///
251    /// * `e` - Access to Soroban environment.
252    /// * `role` - The role to renounce.
253    /// * `caller` - The address of the caller, must be the account that has the
254    ///   role.
255    ///
256    /// # Errors
257    ///
258    /// * [`AccessControlError::RoleNotHeld`] - If the `caller` doesn't have the
259    ///   role.
260    /// * [`AccessControlError::RoleIsEmpty`] - If the role has no members.
261    ///
262    /// # Events
263    ///
264    /// * topics - `["role_revoked", role: Symbol, account: Address]`
265    /// * data - `[caller: Address]`
266    fn renounce_role(e: &Env, role: Symbol, caller: Address) {
267        storage::renounce_role(e, &role, &caller);
268    }
269
270    /// Initiates the admin role transfer.
271    /// Admin privileges for the current admin are not revoked until the
272    /// recipient accepts the transfer.
273    /// Overrides the previous pending transfer if there is one.
274    ///
275    /// # Arguments
276    ///
277    /// * `e` - Access to Soroban environment.
278    /// * `new_admin` - The account to transfer the admin privileges to.
279    /// * `live_until_ledger` - The ledger number at which the pending transfer
280    ///   expires. If `live_until_ledger` is `0`, the pending transfer is
281    ///   cancelled. `live_until_ledger` argument is implicitly bounded by the
282    ///   maximum allowed TTL extension for a temporary storage entry and
283    ///   specifying a higher value will cause the code to panic.
284    ///
285    /// # Errors
286    ///
287    /// * [`crate::role_transfer::RoleTransferError::NoPendingTransfer`] - If
288    ///   trying to cancel a transfer that doesn't exist.
289    /// * [`crate::role_transfer::RoleTransferError::InvalidLiveUntilLedger`] -
290    ///   If the specified ledger is in the past.
291    /// * [`crate::role_transfer::RoleTransferError::InvalidPendingAccount`] -
292    ///   If the specified pending account is not the same as the provided `new`
293    ///   address.
294    /// * [`AccessControlError::AdminNotSet`] - If admin account is not set.
295    ///
296    /// # Events
297    ///
298    /// * topics - `["admin_transfer_initiated", current_admin: Address]`
299    /// * data - `[new_admin: Address, live_until_ledger: u32]`
300    ///
301    /// # Notes
302    ///
303    /// * Authorization for the current admin is required.
304    fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) {
305        storage::transfer_admin_role(e, &new_admin, live_until_ledger);
306    }
307
308    /// Completes the 2-step admin transfer.
309    ///
310    /// # Arguments
311    ///
312    /// * `e` - Access to Soroban environment.
313    ///
314    /// # Events
315    ///
316    /// * topics - `["admin_transfer_completed", new_admin: Address]`
317    /// * data - `[previous_admin: Address]`
318    ///
319    /// # Errors
320    ///
321    /// * [`crate::role_transfer::RoleTransferError::NoPendingTransfer`] - If
322    ///   there is no pending transfer to accept.
323    /// * [`AccessControlError::AdminNotSet`] - If admin account is not set.
324    fn accept_admin_transfer(e: &Env) {
325        storage::accept_admin_transfer(e);
326    }
327
328    /// Sets `admin_role` as the admin role of `role`.
329    ///
330    /// # Arguments
331    ///
332    /// * `e` - Access to Soroban environment.
333    /// * `role` - The role to set the admin for.
334    /// * `admin_role` - The new admin role.
335    ///
336    /// # Events
337    ///
338    /// * topics - `["role_admin_changed", role: Symbol]`
339    /// * data - `[previous_admin_role: Symbol, new_admin_role: Symbol]`
340    ///
341    /// # Errors
342    ///
343    /// * [`AccessControlError::AdminNotSet`] - If admin account is not set.
344    ///
345    /// # Notes
346    ///
347    /// * Authorization for the current admin is required.
348    fn set_role_admin(e: &Env, role: Symbol, admin_role: Symbol) {
349        storage::set_role_admin(e, &role, &admin_role);
350    }
351
352    /// Allows the current admin to renounce their role, making the contract
353    /// permanently admin-less. This is useful for decentralization purposes
354    /// or when the admin role is no longer needed. Once the admin is
355    /// renounced, it cannot be reinstated.
356    ///
357    /// # Arguments
358    ///
359    /// * `e` - Access to Soroban environment.
360    ///
361    /// # Errors
362    ///
363    /// * [`AccessControlError::AdminNotSet`] - If no admin account is set.
364    ///
365    /// # Events
366    ///
367    /// * topics - `["admin_renounced", admin: Address]`
368    /// * data - `[]`
369    ///
370    /// # Notes
371    ///
372    /// * Authorization for the current admin is required.
373    fn renounce_admin(e: &Env) {
374        storage::renounce_admin(e);
375    }
376}
377
378// ################## ERRORS ##################
379
380#[contracterror]
381#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
382#[repr(u32)]
383pub enum AccessControlError {
384    Unauthorized = 2000,
385    AdminNotSet = 2001,
386    IndexOutOfBounds = 2002,
387    AdminRoleNotFound = 2003,
388    RoleCountIsNotZero = 2004,
389    RoleNotFound = 2005,
390    AdminAlreadySet = 2006,
391    RoleNotHeld = 2007,
392    RoleIsEmpty = 2008,
393    TransferInProgress = 2009,
394    MaxRolesExceeded = 2010,
395}
396
397// ################## CONSTANTS ##################
398
399const DAY_IN_LEDGERS: u32 = 17280;
400pub const ROLE_EXTEND_AMOUNT: u32 = 90 * DAY_IN_LEDGERS;
401pub const ROLE_TTL_THRESHOLD: u32 = ROLE_EXTEND_AMOUNT - DAY_IN_LEDGERS;
402/// Maximum number of roles that can exist simultaneously.
403pub const MAX_ROLES: u32 = 256;
404
405// ################## EVENTS ##################
406
407/// Event emitted when a role is granted.
408#[contractevent]
409#[derive(Clone, Debug, Eq, PartialEq)]
410pub struct RoleGranted {
411    #[topic]
412    pub role: Symbol,
413    #[topic]
414    pub account: Address,
415    pub caller: Address,
416}
417
418/// Emits an event when a role is granted to an account.
419///
420/// # Arguments
421///
422/// * `e` - Access to Soroban environment.
423/// * `role` - The role that was granted.
424/// * `account` - The account that received the role.
425/// * `caller` - The account that granted the role.
426pub fn emit_role_granted(e: &Env, role: &Symbol, account: &Address, caller: &Address) {
427    RoleGranted { role: role.clone(), account: account.clone(), caller: caller.clone() }.publish(e);
428}
429
430/// Event emitted when a role is revoked.
431#[contractevent]
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub struct RoleRevoked {
434    #[topic]
435    pub role: Symbol,
436    #[topic]
437    pub account: Address,
438    pub caller: Address,
439}
440
441/// Emits an event when a role is revoked from an account.
442///
443/// # Arguments
444///
445/// * `e` - Access to Soroban environment.
446/// * `role` - The role that was revoked.
447/// * `account` - The account that lost the role.
448/// * `caller` - The account that revoked the role (either the admin or the
449///   account itself).
450pub fn emit_role_revoked(e: &Env, role: &Symbol, account: &Address, caller: &Address) {
451    RoleRevoked { role: role.clone(), account: account.clone(), caller: caller.clone() }.publish(e);
452}
453
454/// Event emitted when a role admin is changed.
455#[contractevent]
456#[derive(Clone, Debug, Eq, PartialEq)]
457pub struct RoleAdminChanged {
458    #[topic]
459    pub role: Symbol,
460    pub previous_admin_role: Symbol,
461    pub new_admin_role: Symbol,
462}
463
464/// Emits an event when the admin role for a role changes.
465///
466/// # Arguments
467///
468/// * `e` - Access to Soroban environment.
469/// * `role` - The role whose admin is changing.
470/// * `previous_admin_role` - The previous admin role.
471/// * `new_admin_role` - The new admin role.
472pub fn emit_role_admin_changed(
473    e: &Env,
474    role: &Symbol,
475    previous_admin_role: &Symbol,
476    new_admin_role: &Symbol,
477) {
478    RoleAdminChanged {
479        role: role.clone(),
480        previous_admin_role: previous_admin_role.clone(),
481        new_admin_role: new_admin_role.clone(),
482    }
483    .publish(e);
484}
485
486/// Event emitted when an admin transfer is initiated.
487#[contractevent]
488#[derive(Clone, Debug, Eq, PartialEq)]
489pub struct AdminTransferInitiated {
490    #[topic]
491    pub current_admin: Address,
492    pub new_admin: Address,
493    pub live_until_ledger: u32,
494}
495
496/// Emits an event when an admin transfer is initiated.
497///
498/// # Arguments
499///
500/// * `e` - Access to Soroban environment.
501/// * `current_admin` - The current admin initiating the transfer.
502/// * `new_admin` - The proposed new admin.
503/// * `live_until_ledger` - The ledger number at which the pending transfer will
504///   expire. If this value is `0`, it means the pending transfer is cancelled.
505pub fn emit_admin_transfer_initiated(
506    e: &Env,
507    current_admin: &Address,
508    new_admin: &Address,
509    live_until_ledger: u32,
510) {
511    AdminTransferInitiated {
512        current_admin: current_admin.clone(),
513        new_admin: new_admin.clone(),
514        live_until_ledger,
515    }
516    .publish(e);
517}
518
519/// Event emitted when an admin transfer is completed.
520#[contractevent]
521#[derive(Clone, Debug, Eq, PartialEq)]
522pub struct AdminTransferCompleted {
523    #[topic]
524    pub new_admin: Address,
525    pub previous_admin: Address,
526}
527
528/// Emits an event when an admin transfer is completed.
529///
530/// # Arguments
531///
532/// * `e` - Access to Soroban environment.
533/// * `previous_admin` - The previous admin.
534/// * `new_admin` - The new admin who accepted the transfer.
535pub fn emit_admin_transfer_completed(e: &Env, previous_admin: &Address, new_admin: &Address) {
536    AdminTransferCompleted { new_admin: new_admin.clone(), previous_admin: previous_admin.clone() }
537        .publish(e);
538}
539
540/// Event emitted when the admin role is renounced.
541#[contractevent]
542#[derive(Clone, Debug, Eq, PartialEq)]
543pub struct AdminRenounced {
544    #[topic]
545    pub admin: Address,
546}
547
548/// Emits an event when the admin role is renounced.
549///
550/// # Arguments
551///
552/// * `e` - Access to Soroban environment.
553/// * `admin` - The admin that renounced the role.
554pub fn emit_admin_renounced(e: &Env, admin: &Address) {
555    AdminRenounced { admin: admin.clone() }.publish(e);
556}