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