Join our community of builders on

Telegram!Telegram
Access

Access Control

Source Code

Overview

The Access Control module provides a comprehensive role-based access control system for Soroban contracts. It enables developers to manage permissions through a hierarchical role system, with a renounceable single overarching admin and customizable role assignments.

Key Concepts

Admin Management

The system features a single top-level admin with privileges to call any function in the AccessControl trait. This admin must be set during contract initialization for the module to function properly. This overarching admin can renounce themselves for decentralization purposes.

Admin transfers are implemented as a two-step process to prevent accidental or malicious takeovers:

  1. The current admin initiates the transfer by specifying the new admin and an expiration time (live_until_ledger).
  2. The designated new admin must explicitly accept the transfer to complete it.

Until the transfer is accepted, the original admin retains full control and can override or cancel the transfer by initiating a new one or using a live_until_ledger of 0.

Role Hierarchy

The module supports a hierarchical role system where each role can have an "admin role" assigned to it. For example:

  • Create roles minter and minter_admin
  • Assign minter_admin as the admin role for the minter role
  • Accounts with the minter_admin role can grant/revoke the minter role to other accounts

This allows for creating complex organizational structures with chains of command and delegated authority.

Setting Up Role Hierarchies

Here's how to establish and use role hierarchies in practice:

use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol};
use stellar_access::access_control::{self as access_control, AccessControl};

const MANAGER_ROLE: Symbol = symbol_short!("manager");
const GUARDIAN_ROLE: Symbol = symbol_short!("guardian");

#[contract]
pub struct MyContract;

#[contractimpl]
impl MyContract {
    pub fn __constructor(e: &Env, admin: Address, manager: Address) {
        // Set the contract admin
        access_control::set_admin(e, &admin);
        
        // 1. Set MANAGER_ROLE as the admin role for GUARDIAN_ROLE:
        // accounts with MANAGER_ROLE can manage accounts with GUARDIAN_ROLE
        access_control::set_role_admin_no_auth(e, &admin, &GUARDIAN_ROLE, &MANAGER_ROLE);

        // 2. Admin grants MANAGER_ROLE to the manager account
        access_control::grant_role_no_auth(e, &admin, &manager, &MANAGER_ROLE);
    }

    pub fn manage_guardians(e: &Env, manager: Address, guardian1: Address, guardian2: Address) {
        // Manager must be authorized
        manager.require_auth();
        
        // 3. Now the manager can grant GUARDIAN_ROLE to other accounts
        access_control::grant_role_no_auth(e, &manager, &guardian1, &GUARDIAN_ROLE);
        access_control::grant_role_no_auth(e, &manager, &guardian2, &GUARDIAN_ROLE);
        
        // Manager can also revoke GUARDIAN_ROLE
        access_control::revoke_role_no_auth(e, &manager, &guardian1, &GUARDIAN_ROLE);
    }
}

In this example:

  1. The admin sets MANAGER_ROLE as the admin role for GUARDIAN_ROLE using set_role_admin()
  2. The admin grants the MANAGER_ROLE role to the manager account
  3. The manager can now grant/revoke the GUARDIAN_ROLE role to other accounts without requiring admin intervention

Role Enumeration

The system tracks account-role pairs in storage with additional enumeration logic:

  • When a role is granted to an account, the pair is stored and added to enumeration storage
  • When a role is revoked, the pair is removed from storage and enumeration
  • If all accounts are removed from a role, the helper storage items become empty or 0

Roles exist only through their relationships with accounts, so a role with zero accounts is indistinguishable from a role that never existed.

Procedural Macros

The module includes several procedural macros to simplify authorization checks in your contract functions. These macros are divided into two categories:

Authorization-Enforcing Macros

These macros automatically call require_auth() on the specified account before executing the function:

@only_admin

Restricts access to the contract admin only:

#[only_admin]
pub fn admin_function(e: &Env) {
    // Only the admin can call this function
    // require_auth() is automatically called
}

@only_role

Restricts access to accounts with a specific role:

#[only_role(caller, "minter")]
pub fn mint(e: &Env, caller: Address, to: Address, token_id: u32) {
    // Only accounts with the "minter" role can call this
    // require_auth() is automatically called on caller
}

@only_any_role

Restricts access to accounts with any of the specified roles:

#[only_any_role(caller, ["minter", "burner"])]
pub fn multi_role_action(e: &Env, caller: Address) {
    // Accounts with either "minter" or "burner" role can call this
    // require_auth() is automatically called on caller
}

Role-Checking Macros

These macros check role membership but do not enforce authorization. You must manually call require_auth() if needed:

@has_role

Checks if an account has a specific role:

#[has_role(caller, "minter")]
pub fn conditional_mint(e: &Env, caller: Address, to: Address, token_id: u32) {
    // Checks if caller has "minter" role, but doesn't call require_auth()
    caller.require_auth(); // Must manually authorize if needed
}

@has_any_role

Checks if an account has any of the specified roles:

#[has_any_role(caller, ["minter", "burner"])]
pub fn multi_role_check(e: &Env, caller: Address) {
    // Checks if caller has either role, but doesn't call require_auth()
    caller.require_auth(); // Must manually authorize if needed
}

Usage Example

Here’s a simple example of using the Access Control module:

use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env};
use stellar_access::access_control::{self as access_control, AccessControl};
use stellar_macros::{has_role, only_admin};

#[contract]
pub struct MyContract;

#[contractimpl]
impl MyContract {
    pub fn __constructor(e: &Env, admin: Address) {
        // Set the contract admin
        access_control::set_admin(e, &admin);

        // Create a "minter" role with admin as its admin
        access_control::set_role_admin_no_auth(e, &symbol_short!("minter"), &symbol_short!("admin"));
    }


    #[only_admin]
    pub fn admin_restricted_function(e: &Env) -> Vec<String> {
        vec![&e, String::from_str(e, "seems sus")]
    }


    // we want `require_auth()` provided by the macro, since there is no
    // `require_auth()` in `Base::mint`.
    #[only_role(caller, "minter")]
    pub fn mint(e: &Env, caller: Address, to: Address, token_id: u32) {
        Base::mint(e, &to, token_id)

    }


    // allows either minter or burner role, does not enforce `require_auth` in the macro
    #[has_any_role(caller, ["minter", "burner"])]
    pub fn multi_role_action(e: &Env, caller: Address) -> String {
        caller.require_auth();
        String::from_str(e, "multi_role_action_success")
    }


    // allows either minter or burner role AND enforces `require_auth` in the macro
    #[only_any_role(caller, ["minter", "burner"])]
    pub fn multi_role_auth_action(e: &Env, caller: Address) -> String {

        String::from_str(e, "multi_role_auth_action_success")
    }
}

Benefits and Trade-offs

Benefits

  • Flexible role-based permission system
  • Hierarchical role management
  • Secure admin transfer process
  • Admin is renounceable
  • Easy integration with procedural macros

Trade-offs

  • More complex than single-owner models like Ownable

See Also