Access Control
Access control on smart contracts decides who can mint, pause, upgrade, or change parameters. OpenZeppelins Ownable, AccessControl, and AccessManager cover most cases. Pair with a multisig + timelock for production owners; pair with role-based admin chains for multi-team protocols.
Ownable, AccessControl, multisig + timelock
EXAMPLE
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// ============================================================
// 1) Ownable — simplest: single owner can do privileged actions
// ============================================================
import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol';
contract Treasury is Ownable {
constructor(address admin) Ownable(admin) {}
function withdraw(address payable to, uint256 amount) external onlyOwner {
to.transfer(amount);
}
}
// ============================================================
// 2) AccessControl — role-based admin with optional admin-of-admin
// ============================================================
import { AccessControl } from '@openzeppelin/contracts/access/AccessControl.sol';
contract Token is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256('MINTER_ROLE');
bytes32 public constant PAUSER_ROLE = keccak256('PAUSER_ROLE');
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
// _mint(to, amount); // from ERC20
}
function pause() external onlyRole(PAUSER_ROLE) {
// _pause(); // from Pausable
}
}
// DEFAULT_ADMIN_ROLE can grant + revoke any role by default.
// Lock that role to a multisig in production:
// token.grantRole(DEFAULT_ADMIN_ROLE, multisig);
// token.revokeRole(DEFAULT_ADMIN_ROLE, msg.sender);
// ============================================================
// 3) Multisig + Timelock — the production owner pattern
// ============================================================
// Gnosis Safe (multisig): N-of-M signers approve any tx.
// TimelockController: queued tx waits T seconds before execution.
//
// Architecture:
// token.owner = Timelock
// Timelock proposers = MultisigA
// Timelock executors = MultisigB (or address(0) = anyone)
//
// Workflow:
// 1) Multisig A queues a tx in Timelock (e.g. pause())
// 2) Wait min delay (e.g. 24-48h)
// 3) Anyone can execute() the queued tx
//
// Effect:
// - No single signer can rug
// - A compromised admin key has at least 24h before damage
// - Users have time to exit if a malicious proposal is queued
import { TimelockController } from '@openzeppelin/contracts/governance/TimelockController.sol';
contract MyTimelock is TimelockController {
constructor(uint256 minDelay, address[] memory proposers, address[] memory executors)
TimelockController(minDelay, proposers, executors, address(0)) {}
}
// ============================================================
// 4) AccessManager — single contract managing roles across many targets
// ============================================================
// OZ AccessManager (v5+) centralises role decisions for an entire protocol.
// Each protected function declares a 'role'; AccessManager decides who has it.
// Useful when you have 10+ contracts that need consistent role policy.
import { AccessManager } from '@openzeppelin/contracts/access/manager/AccessManager.sol';
// AccessManager exposes:
// grantRole(roleId, account, executionDelay)
// restrict + execute helpers
// per-target function -> role mappings
// ============================================================
// 5) Tests (Foundry) — verify access control
// ============================================================
import 'forge-std/Test.sol';
contract AccessTest is Test {
Token token;
address admin = makeAddr('admin');
address user = makeAddr('user');
function setUp() public {
vm.prank(admin);
token = new Token(admin);
}
function test_only_minter_can_mint() public {
vm.prank(admin);
token.mint(user, 100); // ok
vm.prank(user);
vm.expectRevert();
token.mint(user, 100);
}
function test_admin_can_grant_minter() public {
bytes32 role = token.MINTER_ROLE();
vm.prank(admin);
token.grantRole(role, user);
vm.prank(user);
token.mint(user, 100);
}
}
// ============================================================
// 6) Decision matrix
// ============================================================
// Single owner, simple contract -> Ownable + multisig
// Many privileged operations, distinct teams -> AccessControl
// Time-delayed admin actions, user trust -> + Timelock
// Many contracts sharing policy -> AccessManager
// Decentralised governance -> Governor + Timelock + token voting
// ============================================================
// 7) Pitfalls
// ============================================================
// - DEFAULT_ADMIN_ROLE left on the deployer EOA -> a leaked key compromises everything
// - No timelock on admin actions -> rug risk
// - Forgetting to revoke the deployer role after grant_role(multisig)
// - Mixing Ownable + AccessControl unpredictably
// - Hardcoded role bytes32 instead of keccak256('NAME') -> typos compile
Why it matters
A production smart-contract owner is a multisig behind a timelock — not an EOA you control. The combo means no single compromised key can move funds or change parameters instantly, and users get time to exit on a malicious proposal. Get the deploy script right once and the protocol becomes structurally harder to rug.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// OpenZeppelin AccessControl gives you role-based gating.
import "@openzeppelin/contracts/access/AccessControl.sol";
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function mint(address to, uint256 id) external onlyRole(MINTER_ROLE) { _mint(to, id); }
Try it Yourself »
Discussion
Loading…