Access Control

Implement Access Control in a Vyper smart contract

In this article we are going to implement access control feature in a Vyper smart contract. Access control here means roles and its related process, such as granting roles, revoking roles, and renouncing roles. Let's initialize our Mamba project directory.


(.venv) $ mkdir access_control
(.venv) $ cd access_control
(.venv) $ mamba init

Create a new file, contracts/AccessContract.vy. Add the following code to the file:


"""
@title An AccessControl design pattern
@license MIT
@author Arjuna Sky Kok
@notice Translated from OpenZeppelin's AccessControl code: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/AccessControl.sol
"""

interface AccessControl:
    def hasRole(role: bytes32, account: address) -> bool: view
    def getRoleAdmin(role: bytes32) -> bytes32: view

_roles: HashMap[bytes32, HashMap[address, bool]]
_admin_roles: HashMap[bytes32, bytes32]

DEFAULT_ADMIN_ROLE: constant(bytes32) = EMPTY_BYTES32

event RoleAdminChanged:
    role: indexed(bytes32)
    previousAdminRole: indexed(bytes32)
    newAdminRole: indexed(bytes32)

event RoleGranted:
    role: indexed(bytes32)
    account: indexed(address)
    sender: indexed(address)

event RoleRevoked:
    role: indexed(bytes32)
    account: indexed(address)
    sender: indexed(address)

@external
@view
def hasRole(role: bytes32, account: address) -> bool:
    return self._roles[role][account]

@external
@view
def getRoleAdmin(role: bytes32) -> bytes32:
    return self._admin_roles[role]

@internal
def _grantRole(role: bytes32, account: address, sender: address):
    if not AccessControl(self).hasRole(role, account):
        self._roles[role][account] = True
        log RoleGranted(role, account, sender)

@internal
def _revokeRole(role: bytes32, account: address, sender: address):
    if AccessControl(self).hasRole(role, account):
        self._roles[role][account] = False
        log RoleRevoked(role, account, sender)

@internal
def _setupRole(role: bytes32, account: address, sender: address):
    self._grantRole(role, account, sender)

@internal
def _setRoleAdmin(role: bytes32, adminRole: bytes32):
    log RoleAdminChanged(role, self._admin_roles[role], adminRole)
    self._admin_roles[role] = adminRole

@external
def grantRole(role: bytes32, account: address):
    admin: bytes32 = AccessControl(self).getRoleAdmin(role)
    assert AccessControl(self).hasRole(admin, msg.sender), "AccessControl: sender must be an admin to grant"

    self._grantRole(role, account, msg.sender)

@external
def revokeRole(role: bytes32, account: address):
    admin: bytes32 = AccessControl(self).getRoleAdmin(role)
    assert AccessControl(self).hasRole(admin, msg.sender), "AccessControl: sender must be an admin to revoke"

    self._revokeRole(role, account, msg.sender)

@external
def renounceRole(role: bytes32, account: address):
    assert account == msg.sender, "AccessControl: can only renounce roles for self"

    self._revokeRole(role, account, msg.sender)

@external
def __init__():
    role: bytes32 = convert(b"article", bytes32)
    adminRole: bytes32 = convert(b"article_admin", bytes32)
    adminAddress: address = msg.sender

    # _setRoleAdmin
    log RoleAdminChanged(role, self._admin_roles[role], adminRole)
    self._admin_roles[role] = adminRole

    # _grantRole
    self._roles[adminRole][adminAddress] = True
    log RoleGranted(adminRole, adminAddress, msg.sender)

    role2: bytes32 = convert(b"news", bytes32)
    adminRole2: bytes32 = convert(b"news_admin", bytes32)
    adminAddress2: address = 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718

    # _setRoleAdmin
    log RoleAdminChanged(role2, self._admin_roles[role2], adminRole2)
    self._admin_roles[role2] = adminRole2

    # _grantRole
    self._roles[adminRole2][adminAddress2] = True
    log RoleGranted(adminRole2, adminAddress2, msg.sender)

We cannot have HashMap inside struct. This is a conscious decision from Vyper team. Because of that, we have to use two HashMap to track the roles and its admin roles like in this snippet:


_roles: HashMap[bytes32, HashMap[address, bool]]
_admin_roles: HashMap[bytes32, bytes32]

The key in the _roles variable is the name of the role, such as editor, moderator, reviewer. The value in the _roles variable is another HashMap which has a key with address data type and a value with bool data type. If the value is true in this HashMap, it means the address belongs to this particular role. Let's see an example:

"editor" (bytes32) -> "address1" (address) -> True (bool)

"editor" (bytes32) -> "address2" (address) -> False (bool)

"moderator" (bytes32) -> "address1" (address) -> False (bool)

"moderator" (bytes32) -> "address2" (address) -> True (bool)

So "address1" belongs to "editor" role but not "moderator" role.

The _admin_roles is a HashMap which has a key with bytes32 data type and a value with bytes32 data type. It tells which role is the admin for a certain role. You may think the value of this HashMap should be address data type variable. But that limits the admin to be only one account (which is fine if that's what you want). But with this setting, we can have more than one admin for a role.

"editor" (bytes32) -> "editor_admin" (bytes32)

So any address or account which belongs to the "editor_admin" role is the admin of the "editor" role. If you wanted to make "address9" to become the admin of the "editor" role, you would do this:

"editor_admin" (bytes32) -> "address9" (address) -> True (bool)

Now that is clear, the rest of the code is straightforward. We have 5 external functions: hasRole to check whether a particular account belongs to a role or not, getRoleAdmin to find out which role is the admin of this role, grantRole to add a role to an account, revokeRole to remove the role from an account, renounceRole to remove the role from an account by that account itself.

There are 4 internal functions: _grantRole to support grantRole method, _revokeRole to support revokeRole method, _setupRole is similar to _grantRole, _setRoleAdmin to set the admin role for a certain role. Two functions (_setupRole and _setRoleAdmin) are supposed to be called only in the constructor method.

If you notice, we have interface on the top of the file like in this following code:


interface AccessControl:
    def hasRole(role: bytes32, account: address) -> bool: view
    def getRoleAdmin(role: bytes32) -> bytes32: view

Vyper programming language has a restriction on which you cannot call an external function in another external function so using interface is a way to overcome that limitation. For example in the grantRole method, we use interface to call the external function:


@external
def grantRole(role: bytes32, account: address):
    admin: bytes32 = AccessControl(self).getRoleAdmin(role)
    assert AccessControl(self).hasRole(admin, msg.sender), "AccessControl: sender must be an admin to grant"

    self._grantRole(role, account, msg.sender)

As you can see, we call the getRoleAdmin by using interface: AccessControl(self).getRoleAdmin. The interface accepts an address. self is the address of this smart contract.

If you looked at the code in the constructor method, you would wonder why we don't execute the internal methods instead of copying and pasting the content of the internal methods. I did this because right now Vyper got a bug on which you cannot call internal methods inside the constructor method. I will change the code if the bug is fixed. This is the code snippet inside the constructor method:


@external
def __init__():
    role: bytes32 = convert(b"article", bytes32)
    adminRole: bytes32 = convert(b"article_admin", bytes32)
    adminAddress: address = msg.sender

    # _setRoleAdmin
    log RoleAdminChanged(role, self._admin_roles[role], adminRole)
    self._admin_roles[role] = adminRole

    # _grantRole
    self._roles[adminRole][adminAddress] = True
    log RoleGranted(adminRole, adminAddress, msg.sender)

The other thing is we use bytes32 data type for role. We convert the bytes string to the bytes32 data type with convert built-in function: convert(b"article", bytes32). It would append "\x00" bytes to the right of "article" bytes string until 32 bytes length (including the length of the "article" bytes string). But this is not the only way. You could transform the "article" string to the bytes32 data type with keccak256 function. This is useful if you have a role which is longer than 32 bytes.

Compile the smart contract.


(.venv) $ mamba compile

Then we can write the test file, test/test_access_control.py. Add the following code to it:


from black_mamba.testlib import contract, eth_tester
import pytest
from eth_tester.exceptions import TransactionFailed


def test_init(eth_tester):
    accounts = eth_tester.get_accounts()
    ac_contract = contract("AccessControl", [])

    article_bytes32 = b"article".ljust(32, b"\x00")
    article_admin_bytes32 = b"article_admin".ljust(32, b"\x00")
    log = ac_contract.events.RoleAdminChanged.getLogs()[0]
    assert log["args"]["role"] == article_bytes32
    assert log["args"]["previousAdminRole"] == b"\x00" * 32
    assert log["args"]["newAdminRole"] == article_admin_bytes32

    news_bytes32 = b"news".ljust(32, b"\x00")
    news_admin_bytes32 = b"news_admin".ljust(32, b"\x00")
    log = ac_contract.events.RoleAdminChanged.getLogs()[1]
    assert log["args"]["role"] == news_bytes32
    assert log["args"]["previousAdminRole"] == b"\x00" * 32
    assert log["args"]["newAdminRole"] == news_admin_bytes32

def test_getRoleAdmin(eth_tester):
    accounts = eth_tester.get_accounts()
    ac_contract = contract("AccessControl", [])

    article_bytes32 = b"article".ljust(32, b"\x00")
    article_admin_bytes32 = b"article_admin".ljust(32, b"\x00")
    news_bytes32 = b"news".ljust(32, b"\x00")
    news_admin_bytes32 = b"news_admin".ljust(32, b"\x00")
    assert ac_contract.functions.getRoleAdmin(article_bytes32).call() == article_admin_bytes32
    assert ac_contract.functions.getRoleAdmin(news_bytes32).call() == news_admin_bytes32

def test_hasRole(eth_tester):
    accounts = eth_tester.get_accounts()
    ac_contract = contract("AccessControl", [])

    article_admin_bytes32 = b"article_admin".ljust(32, b"\x00")
    news_admin_bytes32 = b"news_admin".ljust(32, b"\x00")
    assert ac_contract.functions.hasRole(article_admin_bytes32, accounts[0]).call() == True
    assert ac_contract.functions.hasRole(news_admin_bytes32, accounts[3]).call() == True
    assert ac_contract.functions.hasRole(article_admin_bytes32, accounts[3]).call() == False
    assert ac_contract.functions.hasRole(news_admin_bytes32, accounts[0]).call() == False

def test_grantRole(eth_tester):
    accounts = eth_tester.get_accounts()
    ac_contract = contract("AccessControl", [])

    article_bytes32 = b"article".ljust(32, b"\x00")
    news_bytes32 = b"news".ljust(32, b"\x00")
    assert ac_contract.functions.hasRole(article_bytes32, accounts[1]).call() == False
    ac_contract.functions.grantRole(article_bytes32, accounts[1]).transact()
    assert ac_contract.functions.hasRole(article_bytes32, accounts[1]).call() == True

    log = ac_contract.events.RoleGranted.getLogs()[0]
    assert log["args"]["role"] == article_bytes32
    assert log["args"]["account"] == accounts[1]
    assert log["args"]["sender"] == accounts[0]

    with pytest.raises(TransactionFailed):
        ac_contract.functions.grantRole(news_bytes32, accounts[1]).transact()

def test_revokeRole(eth_tester):
    accounts = eth_tester.get_accounts()
    ac_contract = contract("AccessControl", [])

    article_bytes32 = b"article".ljust(32, b"\x00")
    news_bytes32 = b"news".ljust(32, b"\x00")
    assert ac_contract.functions.hasRole(article_bytes32, accounts[1]).call() == False
    ac_contract.functions.grantRole(article_bytes32, accounts[1]).transact()
    assert ac_contract.functions.hasRole(article_bytes32, accounts[1]).call() == True
    ac_contract.functions.revokeRole(article_bytes32, accounts[1]).transact()
    assert ac_contract.functions.hasRole(article_bytes32, accounts[1]).call() == False

    log = ac_contract.events.RoleRevoked.getLogs()[0]
    assert log["args"]["role"] == article_bytes32
    assert log["args"]["account"] == accounts[1]
    assert log["args"]["sender"] == accounts[0]

    with pytest.raises(TransactionFailed):
        ac_contract.functions.revokeRole(news_bytes32, accounts[1]).transact()

def test_renounceRole(eth_tester):
    accounts = eth_tester.get_accounts()
    ac_contract = contract("AccessControl", [])

    article_admin_bytes32 = b"article_admin".ljust(32, b"\x00")
    news_bytes32 = b"news".ljust(32, b"\x00")
    assert ac_contract.functions.hasRole(article_admin_bytes32, accounts[0]).call() == True
    ac_contract.functions.renounceRole(article_admin_bytes32, accounts[0]).transact()
    assert ac_contract.functions.hasRole(article_admin_bytes32, accounts[0]).call() == False

    log = ac_contract.events.RoleRevoked.getLogs()[0]
    assert log["args"]["role"] == article_admin_bytes32
    assert log["args"]["account"] == accounts[0]
    assert log["args"]["sender"] == accounts[0]

    with pytest.raises(TransactionFailed):
        ac_contract.functions.renounceRole(news_bytes32, accounts[1]).transact()

The test is easy to understand. You can execute the test.


(.venv) $ py.test test/test_access_control.py