One of our goals outlined in our January-March roadmap for OpenZeppelin Contracts is to reduce library complexity. A key idea here is a revamp of our solution for Access Control, something that we've been discussing for some time.
This post will describe what we want to achieve in this re-design and go over possible implementations, with the goal of gathering feedback and choosing the version OpenZeppelin Contracts v3.0 will ship with.
The Goal
We want to have an Access Control library that is easy to use, hard to misuse, and we're confident is appropriate for the security needs of most if not all projects.
Ultimately, we want for usage of this library to be suggested in our security audits.
Scope
Our library will be based around the concept of roles: we want for users to restrict actions so that only accounts that posses a certain role can perform them.
Additionally, we will also handle role management: how they are are granted and revoked.
Assumptions & Threat Model
Because this is a generic library, we can only make broad assumptions about its usage, which will inform design decisions:
- Roles should be transferable. For different reasons, people may need to switch accounts (such as from a hot to a cold wallet): this behavior should be allowed.
- Roles can be granted by other accounts with the role. This is deduced from (1): it is possible to transfer the role to a smart contract that can only be used by a set of accounts, effectively granting the role to all of them.
- Roles should be removable. In the event of a compromised account (e.g. lost or stolen laptop), it should be possible to revoke the role from that account. This is only useful before the compromised account is used by an attacker: at that point, the damage made is system-dependent, potentially catastrophic (minting of tokens, withdrawal of funds, etc.).
Proposals
edit (15-Feb-2020): For the latest proposal, head to this post.
We came up with a number of variants of increasing degrees of sophistication. The final version will probably not be one of these copied verbatim, but rather a mix of the ideas they present.
All of these proposals make use of the Roles library that is already part of the OpenZeppelin Contracts: I suggest giving it a quick review before moving on.
Sidenote: Role Identifiers
All of the proposals below use bytes32
computed as the result of a hash to identify the different roles.
Expand this to learn why this is considered the best approach
- They don't have the issues associated with strings, unless computed off-chain (
web3.utils.soliditySha3("ROLE_NAME")
) - They don't have the issues associated with integers, namely, choosing how to associate a role name to an integer
- Unlike
bytes32 = "string"
, all 256 bits of the identifier are random-ish, increasing the Hamming distance between them - With a consistent convention, clashes are not possible (e.g. by always doing
bytes32 constant X_ROLE = keccak256("X")
) - They don't take up storage by being
constant
- They are reasonably cheap: calling a
pure
function on a contract that returns such a hash costs ~250 gas (this figure includes the overhead of decoding calldata, jumping based on the function selector, etc.) - They can be expanded to be
immutable
once it is released
1. Barebones
This solution is simple and straightforward: any account with a role can grant it and revoke it.
AccessControl.sol
pragma solidity ^0.6.0;
import "./Roles.sol";
contract AccessControl {
using Roles for Roles.Role;
mapping (bytes32 => Roles.Role) _roles;
function hasRole(bytes32 roleId, address account)
public view returns (bool)
{
return _roles[roleId].has(account);
}
function transferRole(bytes32 roleId, address account) external {
require(
hasRole(roleId, msg.sender),
"AccessControl: sender must have the role to transfer"
);
_revokeRole(roleId, msg.sender);
_grantRole(roleId, account);
}
function grantRole(bytes32 roleId, address account) external {
require(
hasRole(roleId, msg.sender),
"AccessControl: sender must have the role to grant"
);
_grantRole(roleId, account);
}
function revokeRole(bytes32 roleId, address account) external {
require(
hasRole(roleId, msg.sender),
"AccessControl: sender must have the role to revoke"
);
_revokeRole(roleId, account);
}
function _grantRole(bytes32 roleId, address account) internal {
_roles[roleId].add(account);
}
function _revokeRole(bytes32 roleId, address account) internal {
_roles[roleId].remove(account);
}
}
Usage
pragma solidity ^0.6.0;
import "../access/AccessControl.sol";
contract AccessControlMock is AccessControl {
bytes32 constant SOME_ROLE = keccak256("SOME_ROLE");
constructor() public {
_grantRole(SOME_ROLE, msg.sender);
}
function foo() public view returns (uint256) {
return address(this).balance;
}
function bar() public view returns (uint256) {
require(hasRole(SOME_ROLE, msg.sender));
return address(this).balance;
}
}
If we removed the external revokeRole
, it'd be up to the user to define their own way to revoke roles. This could be achieved e.g. by creating a second role that has this special power, which might be error-prone.
At the same time, letting any role-bearer revoke their peers might be too powerful and undesirable for what is expected to be an emergency system.
2. Super Admin
This builds on top of the barebones proposal by adding a predefined, all-powerful role that can grant and revoke any role.
AccesControl.sol
pragma solidity ^0.6.0;
import "./Roles.sol";
contract AccessControl {
using Roles for Roles.Role;
mapping (bytes32 => Roles.Role) _roles;
bytes32 constant SUPER_ADMIN_ROLE = keccak256("SUPER_ADMIN_ROLE");
function hasRole(bytes32 roleId, address account)
public view returns (bool)
{
return _roles[roleId].has(account);
}
function transferRole(bytes32 roleId, address account) external {
require(
hasRole(roleId, msg.sender),
"AccessControl: sender must have the role to transfer"
);
_revokeRole(roleId, msg.sender);
_grantRole(roleId, account);
}
function grantRole(bytes32 roleId, address account) external {
require(
hasRole(roleId, msg.sender) || hasRole(SUPER_ADMIN_ROLE, msg.sender),
"AccessControl: sender must have the role to grant"
);
_grantRole(roleId, account);
}
function revokeRole(bytes32 roleId, address account) external {
require(
hasRole(SUPER_ADMIN_ROLE, msg.sender),
"AccessControl: sender must be an admin to revoke"
);
_revokeRole(roleId, account);
}
function _grantRole(bytes32 roleId, address account) internal {
_roles[roleId].add(account);
}
function _revokeRole(bytes32 roleId, address account) internal {
_roles[roleId].remove(account);
}
}
The addition of a baked-in super admin role makes management much easier: super admins are in charge of emergency response (revoking roles), while regular accounts are used for regular operations.
Note that the semantics behind hasRole
have not changed: an admin does not have all roles, that is, it cannot perform actions that other roles can (though it can of course grant itself any role and then perform the action).
A disadvantage of this approach is that it might be too rigid, and creating a more-flexible system (e.g. with multiple levels of administrative power) could require too much custom code.
3. Granular Permissions
This makes a radical change on the Roles
library: instead of each role being binary (an account has it or it doesn't), different permissions are instead associated with each one. These would be:
- Permission to use the role
- Permission to transfer and grant the role
- Permission to revoke the role
(those changes are not included in the sample below, which is only illustrative - it is not complete)
AccessControl.sol
pragma solidity ^0.6.0;
import "./Roles.sol";
contract AccessControl {
using Roles for Roles.Role;
mapping (bytes32 => Roles.Role) _roles;
function hasRole(bytes32 roleId, address account)
public view returns (bool)
{
return _roles[roleId].has(account);
}
function canGrantRole(bytes32 roleId, address account)
public view returns (bool)
{
return _roles[roleId].canGrant(account);
}
function canRevokeRole(bytes32 roleId, address account)
public view returns (bool)
{
return _roles[roleId].canRevoke(account);
}
function grantRole(bytes32 roleId, address account) external {
require(
canGrantRole(roleId, msg.sender)
"AccessControl: sender cannot grant the role"
);
_grantRole(roleId, account);
}
function revokeRole(bytes32 roleId, address account) external {
require(
canRevokeRole(SUPER_ADMIN_ROLE, msg.sender),
"AccessControl: sender cannot revoke the role"
);
_revokeRole(roleId, account);
}
function _grantRole(bytes32 roleId, address account) internal {
_roles[roleId].add(account);
}
function _revokeRole(bytes32 roleId, address account) internal {
_roles[roleId].remove(account);
}
}
This approach is much more flexible, letting users decide which level of granularity they want with their permissions without the library being opinionated about it.
It might also be too flexible. Because we wouldn't want to include enums in the public API functions, we would need to add many external methods, such as allowToGrantRole
, allowToRevokeRole
, disallowToGrantRoles
and disallowToRevokeRoles
(and their internal
variants).
Additionally, constructs like the Super Admin are not easy to make under this system: to give these powers to an account we'd need to call allowToGrantRole
and allowToRevokeRole
for every single role defined in the system.
Conclusion
Each proposal has different trade-offs: I'd like to gather feedback on them to inform the decision of what AccessControl
will look like. It'd be specially useful to learn from real-life scenarios, such as strategies projects have already adopted, or situations where keys were lost and emergency measures had to be taken.
Thanks!