Role-Based Access Control
Problem
My contract has several privileged operations — minting, pausing, upgrading — and I want to hand each of them to a different address (or set of addresses) without creating N independent owner fields. I want the familiar OpenZeppelin AccessControl model: roles as bytes32 identifiers, admin roles that can grant/revoke, and a single uniform guard.
Solution
contract RBAC {
// role id => (holder => granted?)
field roles: mapping<bytes32, mapping<address, bool>>
field role_admin: mapping<bytes32, bytes32>
// Canonical role identifiers (keccak256 of the string)
const ADMIN_ROLE: bytes32 = 0x0000000000000000000000000000000000000000000000000000000000000000
const MINTER_ROLE: bytes32 = keccak256("MINTER_ROLE")
const PAUSER_ROLE: bytes32 = keccak256("PAUSER_ROLE")
const UPGRADER_ROLE: bytes32 = keccak256("UPGRADER_ROLE")
event RoleGranted(role: bytes32 indexed, account: address indexed, sender: address indexed)
event RoleRevoked(role: bytes32 indexed, account: address indexed, sender: address indexed)
event RoleAdminChanged(role: bytes32 indexed, previous: bytes32, current: bytes32)
error MissingRole(role: bytes32, account: address)
error CannotRenounceForOther()
// Deployer bootstraps as ADMIN_ROLE; ADMIN_ROLE is its own admin.
action init() {
roles[ADMIN_ROLE][deployer] = true
role_admin[ADMIN_ROLE] = ADMIN_ROLE
role_admin[MINTER_ROLE] = ADMIN_ROLE
role_admin[PAUSER_ROLE] = ADMIN_ROLE
role_admin[UPGRADER_ROLE] = ADMIN_ROLE
emit RoleGranted(ADMIN_ROLE, deployer, deployer)
}
guard only_role(r: bytes32) {
given roles[r][caller] or revert_with MissingRole(r, caller)
}
action grant_role(role: bytes32, a: address)
only_role(role_admin[role]) {
given !roles[role][a] // idempotent no-op if already granted
roles[role][a] = true
emit RoleGranted(role, a, caller)
}
action revoke_role(role: bytes32, a: address)
only_role(role_admin[role]) {
given roles[role][a]
roles[role][a] = false
emit RoleRevoked(role, a, caller)
}
// Holders can voluntarily drop their own role (cannot be used to kick others).
action renounce_role(role: bytes32, a: address) {
given a == caller or revert_with CannotRenounceForOther()
given roles[role][a]
roles[role][a] = false
emit RoleRevoked(role, a, caller)
}
action set_role_admin(role: bytes32, new_admin: bytes32)
only_role(ADMIN_ROLE) {
let previous = role_admin[role]
role_admin[role] = new_admin
emit RoleAdminChanged(role, previous, new_admin)
}
// --- Example privileged actions using the guard ---
action mint(to: address, amount: u256) only_role(MINTER_ROLE) {
// ... mint logic
}
action pause() only_role(PAUSER_ROLE) {
// ... pause logic
}
action upgrade(new_impl: address) only_role(UPGRADER_ROLE) {
// ... upgrade logic
}
}
Explanation
bytes32role identifiers mirror OpenZeppelin so the same off-chain tooling (Etherscan role dashboards, Defender) works unchanged.ADMIN_ROLEis the zero hash by convention — easy to spot in logs and matchesDEFAULT_ADMIN_ROLE.role_admin[role]is itself a role, meaning you can delegate: e.g., setMINTER_ROLE’s admin toMINTER_ADMIN_ROLEto separate the “who mints” from “who appoints minters.”renounce_rolerequiresa == callerso a compromised admin cannot use it to remove honest operators.- The
only_role(r)guard takes a parameter — this is the idiomatic way to reuse one guard across many actions.
Gas Estimate
| Operation | Gas |
|---|---|
grant_role | ~48,000 |
revoke_role | ~30,000 |
renounce_role | ~28,000 |
has_role (view) | ~3,000 |
| Guard check on action | ~2,500 |
Common Pitfalls
- Single admin bootstrap: If
deployeris the onlyADMIN_ROLEholder and loses their key, nobody can grant roles ever again. Always grantADMIN_ROLEto at least a 2-of-3 multisig at deploy. - Using
owner == calleralongside RBAC: Mixing the two models creates ambiguity. Pick RBAC and migrate legacy owner checks toonly_role(ADMIN_ROLE). - Role id collisions: Always compute ids with
keccak256("ROLE_NAME")at compile time. Do not type them manually. - Forgetting
set_role_admin: By default every role is gated byADMIN_ROLE. If you want a more granular hierarchy, callset_role_adminduring init. - Renounce semantics: OpenZeppelin changed renounce semantics in v5 to require self; match it here to avoid migration surprises.
Variations
Timelocked role grants
Wrap grant_role in a 48h timelock so a compromised admin cannot immediately appoint an attacker. Combine with Time-Locked Admin.
Enumerable roles
Add a mapping<bytes32, address[]> members alongside the bool map so UIs can list every holder. Push on grant, swap-and-pop on revoke.