Access Control

Difficulty: Intermediate

Access control is a fundamental security pattern in smart contract development that determines who can call specific functions. Without proper access control, critical operations like minting tokens, pausing contracts, or withdrawing funds could be executed by anyone. Solidity provides several patterns for implementing access control, ranging from simple single-owner models to sophisticated role-based systems.

The simplest form is the Ownable pattern, where a single address (the contract deployer or a designated owner) has exclusive access to privileged functions. OpenZeppelin's `Ownable` contract provides this out of the box with an `onlyOwner` modifier and functions to transfer or renounce ownership. While simple, this pattern has a single point of failure: if the owner's private key is compromised, the attacker gains full control. It is also overly coarse-grained for complex systems where different addresses should have different levels of access.

Role-Based Access Control (RBAC) is the industry standard for more complex permission systems. OpenZeppelin's `AccessControl` contract allows you to define multiple roles, each represented by a `bytes32` identifier. Addresses can be granted or revoked roles, and functions can require specific roles via the `onlyRole` modifier. Each role has an admin role that controls who can grant and revoke it, creating a hierarchy of permissions. For example, a DeFi protocol might have separate roles for MINTER, PAUSER, UPGRADER, and FEE_MANAGER.

For high-value operations, multi-signature (multi-sig) wallets add an extra layer of security by requiring multiple parties to approve a transaction before it executes. Gnosis Safe (now Safe) is the most widely used multi-sig in the Ethereum ecosystem. Rather than having a single externally owned account (EOA) as the contract owner, teams assign ownership to a multi-sig that requires, say, 3 out of 5 signers to approve critical operations. This distributes trust and makes key compromises far less catastrophic.

When designing access control, consider the principle of least privilege: each role should have the minimum permissions necessary to perform its function. Time-locked admin actions (via a Timelock contract) add a delay between proposing and executing changes, giving the community time to review and potentially exit before controversial changes take effect. Many production protocols combine RBAC with a Timelock and a multi-sig to create a robust governance structure.

Code examples

Role-Based Access Control

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract RoleBasedAccess {
    // Role definitions
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    // role => account => hasRole
    mapping(bytes32 => mapping(address => bool)) private _roles;
    // role => adminRole
    mapping(bytes32 => bytes32) private _roleAdmin;

    bool public paused;
    mapping(address => uint256) public balances;

    event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
    event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender);

    modifier onlyRole(bytes32 role) {
        require(_roles[role][msg.sender], "AccessControl: missing role");
        _;
    }

    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    constructor() {
        // Deployer gets admin role
        _roles[ADMIN_ROLE][msg.sender] = true;
        // Admin role is the admin for all roles by default
        _roleAdmin[MINTER_ROLE] = ADMIN_ROLE;
        _roleAdmin[PAUSER_ROLE] = ADMIN_ROLE;
    }

    function grantRole(bytes32 role, address account)
        external
        onlyRole(_roleAdmin[role])
    {
        _roles[role][account] = true;
        emit RoleGranted(role, account, msg.sender);
    }

    function revokeRole(bytes32 role, address account)
        external
        onlyRole(_roleAdmin[role])
    {
        _roles[role][account] = false;
        emit RoleRevoked(role, account, msg.sender);
    }

    function hasRole(bytes32 role, address account)
        external
        view
        returns (bool)
    {
        return _roles[role][account];
    }

    // Only minters can mint
    function mint(address to, uint256 amount)
        external
        onlyRole(MINTER_ROLE)
        whenNotPaused
    {
        balances[to] += amount;
    }

    // Only pausers can pause/unpause
    function pause() external onlyRole(PAUSER_ROLE) {
        paused = true;
    }

    function unpause() external onlyRole(PAUSER_ROLE) {
        paused = false;
    }
}

This contract implements RBAC from scratch. Roles are bytes32 hashes, each role has an admin role that controls granting/revoking, and different functions require different roles. In production, use OpenZeppelin's AccessControl for a battle-tested implementation.

Ownable with Two-Step Transfer

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract OwnableTwoStep {
    address public owner;
    address public pendingOwner;

    event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);
    }

    function transferOwnership(address newOwner) external onlyOwner {
        pendingOwner = newOwner;
        emit OwnershipTransferStarted(owner, newOwner);
    }

    function acceptOwnership() external {
        require(msg.sender == pendingOwner, "Not pending owner");
        emit OwnershipTransferred(owner, pendingOwner);
        owner = pendingOwner;
        pendingOwner = address(0);
    }

    function renounceOwnership() external onlyOwner {
        emit OwnershipTransferred(owner, address(0));
        owner = address(0);
        pendingOwner = address(0);
    }
}

Two-step ownership transfer prevents accidentally transferring ownership to a wrong address. The new owner must actively accept ownership, confirming they control the address.

Key points

Concepts covered

Ownable, Roles, AccessControl, Multi-sig