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