Access Control

Access control—that is, "who is allowed to do this thing"—is incredibly important in the world of smart contracts. The access control of your contract may govern who can mint tokens, vote on proposals, freeze transfers, and many other things. It is therefore critical to understand how you implement it, lest someone else steals your whole system.

Ownership and Ownable

The most common and basic form of access control is the concept of ownership: there’s an account that is the owner of a contract and can do administrative tasks on it. This approach is perfectly reasonable for contracts that have a single administrative user.

OpenZeppelin Contracts for Stylus provides Ownable for implementing ownership in your contracts.

use openzeppelin_stylus::access::ownable::Ownable;

sol_storage! {
    #[entrypoint]
    struct OwnableExample {
        #[borrow]
        Ownable ownable;
    }
}

#[external]
#[inherit(Ownable)]
impl MyContract {
    fn normal_thing(&self) {
        // anyone can call this normal_thing()
    }

    pub fn special_thing(
        &mut self,
    ) -> Result<(), Vec<u8>> {
        self.ownable.only_owner()?;

        // only the owner can call special_thing()!

        Ok(())
    }
}

At deployment, the owner of an Ownable contract is set to the provided initial_owner parameter.

Ownable also lets you:

  • transfer_ownership from the owner account to a new one, and

  • renounce_ownership for the owner to relinquish this administrative privilege, a common pattern after an initial stage with centralized administration is over.

Removing the owner altogether will mean that administrative tasks that are protected by only_owner will no longer be callable!

Note that a contract can also be the owner of another one! This opens the door to using, for example, a Gnosis Safe, an Aragon DAO, or a totally custom contract that you create.

In this way, you can use composability to add additional layers of access control complexity to your contracts. Instead of having a single regular Ethereum account (Externally Owned Account, or EOA) as the owner, you could use a 2-of-3 multisig run by your project leads, for example. Prominent projects in the space, such as MakerDAO, use systems similar to this one.

Role-Based Access Control

While the simplicity of ownership can be useful for simple systems or quick prototyping, different levels of authorization are often needed. You may want for an account to have permission to ban users from a system, but not create new tokens. Role-Based Access Control (RBAC) offers flexibility in this regard.

In essence, we will be defining multiple roles, each allowed to perform different sets of actions. An account may have, for example, 'moderator', 'minter' or 'admin' roles, which you will then check for instead of simply using only_owner. This check can be enforced through the only_role modifier. Separately, you will be able to define rules for how accounts can be granted a role, have it revoked, and more.

Most software uses access control systems that are role-based: some users are regular users, some may be supervisors or managers, and a few will often have administrative privileges.

Using AccessControl

OpenZeppelin Contracts provides AccessControl for implementing role-based access control. Its usage is straightforward: for each role that you want to define, you will create a new role identifier that is used to grant, revoke, and check if an account has that role.

Here’s a simple example of using AccessControl in an ERC-20 token to define a 'minter' role, which allows accounts that have it create new tokens. Note that the example is unassuming of the way you construct your contract.

sol_storage! {
    #[entrypoint]
    struct Example {
        #[borrow]
        Erc20 erc20;
        #[borrow]
        AccessControl access;
    }
}

// `keccak256("MINTER_ROLE")`
pub const MINTER_ROLE: [u8; 32] = [
    159, 45, 240, 254, 210, 199, 118, 72, 222, 88, 96, 164, 204, 80, 140, 208,
    129, 140, 133, 184, 184, 161, 171, 76, 238, 239, 141, 152, 28, 137, 86,
    166,
];

#[external]
#[inherit(Erc20, AccessControl)]
impl Example {
    pub const MINTER_ROLE: [u8; 32] = MINTER_ROLE;

    pub fn mint(&mut self, to: Address, amount: U256) -> Result<(), Vec<u8>> {
        if self.access.has_role(Example::MINTER_ROLE, msg::sender()) {
            return Err(Vec::new());
        }
        self.erc20._mint(to, amount)?;
        Ok(())
    }
}
Make sure you fully understand how AccessControl works before using it on your system, or copy-pasting the examples from this guide.

While clear and explicit, this isn’t anything we wouldn’t have been able to achieve with Ownable. Indeed, where AccessControl shines is in scenarios where granular permissions are required, which can be implemented by defining multiple roles.

Let’s augment our ERC-20 token example by also defining a 'burner' role, which lets accounts destroy tokens, and by using the only_role modifier:

sol_storage! {
    #[entrypoint]
    struct Example {
        #[borrow]
        Erc20 erc20;
        #[borrow]
        AccessControl access;
    }
}

// `keccak256("MINTER_ROLE")`
pub const MINTER_ROLE: [u8; 32] = [
    159, 45, 240, 254, 210, 199, 118, 72, 222, 88, 96, 164, 204, 80, 140, 208,
    129, 140, 133, 184, 184, 161, 171, 76, 238, 239, 141, 152, 28, 137, 86,
    166,
];

// `keccak256("BURNER_ROLE")`
pub const BURNER_ROLE: [u8; 32] = [
    60, 17, 209, 108, 186, 255, 208, 29, 246, 156, 225, 196, 4, 246, 52, 14,
    224, 87, 73, 143, 95, 0, 36, 97, 144, 234, 84, 34, 5, 118, 168, 72,
];

#[external]
#[inherit(Erc20, AccessControl)]
impl Example {
    pub const MINTER_ROLE: [u8; 32] = MINTER_ROLE;
    pub const BURNER_ROLE: [u8; 32] = BURNER_ROLE;

    pub fn mint(&mut self, to: Address, amount: U256) -> Result<(), Vec<u8>> {
        self.access.only_role(Example::MINTER_ROLE.into())?;
        self.erc20._mint(to, amount)?;
        Ok(())
    }

    pub fn mint(&mut self, from: Address, amount: U256) -> Result<(), Vec<u8>> {
        self.access.only_role(Example::BURNER_ROLE.into())?;
        self.erc20._burn(to, amount)?;
        Ok(())
    }
}

So clean! By splitting concerns this way, more granular levels of permission may be implemented than were possible with the simpler ownership approach to access control. Limiting what each component of a system is able to do is known as the principle of least privilege, and is a good security practice. Note that each account may still have more than one role, if so desired.

Granting and Revoking Roles

The ERC-20 token example above uses _grant_role, an internal function that is useful when programmatically assigning roles (such as during construction). But what if we later want to grant the 'minter' role to additional accounts?

By default, accounts with a role cannot grant it or revoke it from other accounts: all having a role does is making the has_role check pass. To grant and revoke roles dynamically, you will need help from the role’s admin.

Every role has an associated admin role, which grants permission to call the grant_role and revoke_role functions. A role can be granted or revoked by using these if the calling account has the corresponding admin role. Multiple roles may have the same admin role to make management easier. A role’s admin can even be the same role itself, which would cause accounts with that role to be able to also grant and revoke it.

This mechanism can be used to create complex permissioning structures resembling organizational charts, but it also provides an easy way to manage simpler applications. AccessControl includes a special role, called DEFAULT_ADMIN_ROLE, which acts as the default admin role for all roles. An account with this role will be able to manage any other role, unless _set_role_admin is used to select a new admin role.

Note that, by default, no accounts are granted the 'minter' or 'burner' roles. We assume you use a constructor to set the default admin role as the role of the deployer, or have a different mechanism where you make sure that you are able to grant roles. However, because those roles' admin role is the default admin role, and that role was granted to msg::sender(), that same account can call grant_role to give minting or burning permission, and revoke_role to remove it.

Dynamic role allocation is often a desirable property, for example in systems where trust in a participant may vary over time. It can also be used to support use cases such as KYC, where the list of role-bearers may not be known up-front, or may be prohibitively expensive to include in a single transaction.