-
Notifications
You must be signed in to change notification settings - Fork 11.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
AccessControl Admin Rules #3623
Comments
We're still discussing ideas. If you want to help I think it would be great to put together a proof of concept implementing the 3 points I mentioned. You can share it here in a comment. Please no PRs yet until we pin down the design! |
POC:
|
I would not use Ownable for this. What are the benefits to using it? |
I'd say
It's technically an ownership pattern we are talking about, so to me using Ownable for that is a no brainer. |
I also had one question, should delay be immutable? I was thinking of more along the lines of having an overloaded function for _transferOwnership where the second parameter would be delay. |
The delay should be enforced. The idea of the delay is to have a time period to detect if an admin transfer was malicious and have the ability to cancel it before it comes into effect. It should probably not be immutable but changing it should be done by the admin and with a delay. |
Considering all these "value updatable with a delay", I wonder if we should include a library for that. Something like
(that could be available for different sub-types) That could be a first good issue that would pave the way for this (and other PR) |
@Amxx Hm, I think that abstraction is a bit on the overengineered side. |
@frangio so are you against the idea of a library for delays? Or the library is over engineered you mean. |
Yes I'd say a library for delayed updates is not necessary. |
Changing delays is a bit tricky: if decreasing the delay, you'd then need to wait at least the difference between delays before making the change, and if increasing it you technically don't need to wait at all. But you also don't want for delays to e.g. be immediately increased to 100 years. We chose to have an absolute maximum delay (2 years), and a minimum delay of 5 days whenever a delay is changed, including when the delay is increased. Given you don't have any other delay-changing mechanism, the complexity of the above, and that changing delays is a very infrequent action, I'd just make it immutable. |
@nventuro, very interesting. I really liked the idea of minimum and maximum delay since oopsies can happen there. What I suggest is this. We can do an immutable min and max delay and maybe should remove the delay variable altogether and except go with providing timestamp (just how @Amxx had implemented it). We can check if timestamp lies in the bound or not. The thing is this, no one is going to manually put timestamp in real life use case, it will be programmatically called, hence the frontend can plugin the desired timestamp accordingly. Having a default delay sounds good on paper, but I believe providing custom timestamp while checking if it lies in the bound would be best. This will sort out a lot of complexity. But I could be wrong, there could be a case where providing timestamp won't be feasible at all. |
The goal of the delay is to ensure there is time to cancel a malicious transfer. A malicious transfer would always use the minimum delay possible, and the option to transfer with a longer delay wouldn't make a difference there, so what would be the purpose? I'd say let's just do an immutable delay set in the constructor. |
Fair enough, btw should ownable be used or do you have from scratch
implementation in mind?
…On Wed, 24 Aug, 2022, 2:48 am Francisco, ***@***.***> wrote:
The goal of the delay is to ensure there is time to cancel a malicious
transfer. A malicious transfer would always use the minimum delay possible,
and the option to transfer with a longer delay wouldn't make a difference
there, so what would be the purpose?
I'd say let's just do an immutable delay set in the constructor.
—
Reply to this email directly, view it on GitHub
<#3623 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ACZQUYB6ZNBQ5F3BSX6CHPTV2U5ZZANCNFSM56XGEOCQ>
.
You are receiving this because you commented.Message ID:
***@***.***>
|
IMO it should not be Ownable. |
I made a prototype without using the // SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.5.0) (access/AccessControlEnumerable.sol)
pragma solidity ^0.8.0;
import "./AccessControl.sol";
/**
* @dev Extension of {AccessControl} that allows to specify special rules to manage
* the `DEFAULT_ADMIN_ROLE` owner, which is a sensitive role.
*
* If a specific role doesn't have an `adminRole` assigned, the holder of the
* `DEFAULT_ADMIN_ROLE` will have the ability to manage it, as determined by the
* function {getRoleAdmin}.
*
* This contract implements the following risk mitigations:
*
* - Only one account holds the `DEFAULT_ADMIN_ROLE`.
* - Enforce a 2-step process to transfer the `DEFAULT_ADMIN_ROLE` to another account.
* - Enforce a configurable delay between the two steps, with the ability to cancel in between.
*
* NOTE: `delay` is only configurable in the constructor
*/
abstract contract AccessControlAdminRules is AccessControl {
uint32 private immutable _delay;
address public pendingAdminOwner;
uint64 public pendingAdminOwnerTimer;
address private _previousAdminOwner;
event AdminRoleChangeStarted(address indexed previousOwner, address indexed newOwner, uint64 timer);
/**
* @dev Initializes the contract setting a delay.
*
* There should be always an initial admin, since {_transferAdmin} revokes the role
* and the zero address never emitted an {AccessControl-GrantRole} event.
*/
constructor(uint32 initialDelay, address initialAdminOwner) {
super.grantRole(DEFAULT_ADMIN_ROLE, initialAdminOwner);
_previousAdminOwner = initialAdminOwner;
_delay = initialDelay;
}
/**
* @dev `DEFAULT_ADMIN_ROLE` can't be granted to ensure there's always 1 owner of it.
* Ownership updates of `DEFAULT_ADMIN_ROLE` should be done using {_transferAdmin} instead.
*/
function _grantRole(bytes32 role, address account) internal override {
require(role != DEFAULT_ADMIN_ROLE, "AccessControlAdminRules: Only one DEFAULT_ADMIN_ROLE is allowed");
super._grantRole(role, account);
}
/**
* @dev `DEFAULT_ADMIN_ROLE` can't be revoked to ensure there's always 1 owner of it.
* Ownership updates of `DEFAULT_ADMIN_ROLE` should be done using {_transferAdmin} instead.
*/
function _revokeRole(bytes32 role, address account) internal override {
require(role != DEFAULT_ADMIN_ROLE, "AccessControlAdminRules: Only one DEFAULT_ADMIN_ROLE is allowed");
super._revokeRole(role, account);
}
/**
* @dev Start `DEFAULT_ADMIN_ROLE` transfership by setting new pending owner and a timer.
* Updates `_previousAdminOwner` specifically after `AdminRoleChangeStarted` is emitted.
*
* NOTE: Owner can call with address(0) during the delay to cancel
*/
function transferAdmin(address newOwner) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
pendingAdminOwnerTimer = uint64(block.timestamp) + _delay;
pendingAdminOwner = newOwner;
emit AdminRoleChangeStarted(_previousAdminOwner, pendingAdminOwner, pendingAdminOwnerTimer);
_previousAdminOwner = newOwner;
}
/**
* @dev Completes the admin transfership after delay has passed
*/
function acceptAdmin() public virtual {
require(pendingAdminOwner == _msgSender(), "AccessControlAdminRules: Only pending owner can accept ownership");
require(pendingAdminOwnerTimer < block.timestamp, "AccessControlAdminRules: Pending ownership delay");
_transferAdmin(_previousAdminOwner, pendingAdminOwner);
}
/**
* @dev Transfers the `DEFAULT_ADMIN_ROLE`, effectively allowing only 1 owner of the role.
*/
function _transferAdmin(address previousOwner, address newOwner) internal virtual {
super.revokeRole(DEFAULT_ADMIN_ROLE, previousOwner);
super.grantRole(DEFAULT_ADMIN_ROLE, newOwner);
// Cancels any other pending transfership process
delete pendingAdminOwnerTimer;
delete pendingAdminOwner;
}
} I'd say it is almost the same as the one @Amxx shared, but I didn't include btw, I'm not sure if we should remove the |
The use of An alternative proposal would be to keep the count of members of the role, and add function _revokeRole(bytes32 role, address account) internal override {
if (role == DEFAULT_ADMIN_ROLE) {
require(_pendingAdminTimer > 0 && _pendingAdminTimer < block.timestamp);
_pendingAdminTimer = 0;
super._revokeRole(role, account);
if (_pendingAdmin != address(0)) {
_grantRole(role, _pendingAdmin);
}
} else {
super._revokeRole(role, account);
}
} What do people think of this?
Note that the prototypes above are also missing a way to cancel a pending admin transfer, which I think had been suggested needs to be a part of this. |
I like the idea of removing
We can also avoid to start a transfer admin if a timer has been set. It can be something like these: pragma solidity ^0.8.0;
import "./AccessControl.sol";
/**
* @dev Extension of {AccessControl} that allows to specify special rules to manage
* the `DEFAULT_ADMIN_ROLE` owner, which is a sensitive role.
*
* If a specific role doesn't have an `adminRole` assigned, the holder of the
* `DEFAULT_ADMIN_ROLE` will have the ability to manage it, as determined by the
* function {getRoleAdmin}.
*
* This contract implements the following risk mitigations:
*
* - Only one account holds the `DEFAULT_ADMIN_ROLE`.
* - Enforce a 2-step process to transfer the `DEFAULT_ADMIN_ROLE` to another account.
* - Enforce a configurable delay between the two steps, with the ability to cancel in between.
*
* NOTE: `delay` is only configurable in the constructor
*/
abstract contract AccessControlAdminRules is AccessControl {
uint32 private immutable _delay;
bool private _adminOwned;
address public pendingAdmin;
uint64 public pendingAdminTimer;
event AdminRoleChangeStarted(address indexed previousOwner, address indexed newOwner, uint64 timer);
/**
* @dev Initializes the contract with an admin transfer delay
*/
constructor(uint32 initialDelay) {
_delay = initialDelay;
}
/**
* @dev See {AccessControl-_grantRole}.
*
* If the role is `DEFAULT_ADMIN_ROLE`, it can only be granted to a different account
* if it's not already owned. Calling `grantRole` for an already admin owner is allowed.
*/
function _grantRole(bytes32 role, address account) internal override {
if (role == DEFAULT_ADMIN_ROLE) {
if (_adminOwned) {
require(
hasRole(role, account), // NOOP allowed
"AccessControlAdminRoles: Only current owner can be granted the role again."
);
} else {
require(!_adminOwned, "AccessControlAdminRules: Admin role is already owned");
_adminOwned = true;
}
}
super._grantRole(role, account);
}
/**
* @dev See {AccessControl-_revokeRole}.
*
* If the role is `DEFAULT_ADMIN_ROLE`, it can only be revoked after a timer has been set and met, and
* it also grants the role to a previously set `pendingOwner`.
*
* See {transferAdmin}.
*/
function _revokeRole(bytes32 role, address account) internal override {
if (role == DEFAULT_ADMIN_ROLE) {
require(pendingAdminTimer > 0, "AccessControlAdminRules: Timer for admin transfer not set");
require(pendingAdminTimer < block.timestamp, "AccessControlAdminRules: Timer for admin transfer not met");
delete pendingAdminTimer;
if (_adminOwned) _adminOwned = false;
// If it wasn't owned before, `_revokeRole` doesn't revoke it.
super._revokeRole(role, account);
address _newOwner = pendingAdmin;
delete pendingAdmin;
_grantRole(role, _newOwner); // Address 0 is not a NOO for `renounceRole`
} else {
super._revokeRole(role, account);
}
}
/**
* @dev Start `DEFAULT_ADMIN_ROLE` transfership by setting new pending owner and a timer.
*/
function transferAdmin(address previousOwner, address newOwner) external virtual onlyRole(DEFAULT_ADMIN_ROLE) {
require(pendingAdminTimer == 0, "AccessControlAdminRules: Timer for admin transfer already set");
pendingAdminTimer = uint64(block.timestamp) + _delay;
pendingAdmin = newOwner;
if (!_adminOwned) {
require(
previousOwner == address(0),
"AccessControlAdminRules: Previous owner can only be address 0 if owner hasn't been owned"
);
} else {
_checkRole(DEFAULT_ADMIN_ROLE, previousOwner);
}
emit AdminRoleChangeStarted(previousOwner, pendingAdmin, pendingAdminTimer);
}
/**
* @dev Cancels {transferAdmin}.
*/
function cancelTransferAdmin() external virtual onlyRole(DEFAULT_ADMIN_ROLE) {
require(
pendingAdminTimer > block.timestamp,
"AccessControlAdminRules: Can't cancel admin transfer if timer is already met"
);
delete pendingAdmin;
delete pendingAdminTimer;
// Emit event?
}
} However, I don't like the
Still, I don't like number 2 and the fact that there would be an intermediate time with no DEFAULT_ADMIN_ROLE if we decide to keep the |
I'm showked by how complexe Also, why do we introduce a new function |
IMO, the same features can be achieved with a significantly better readability (which I believe is one of our guidelines) Code example// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/access/Ownable2Steps.sol";
contract AccessControlAdmin is AccessControl, Ownable2Steps
{
uint256 private immutable _delay;
uint256 private _deadline;
constructor(uint256 delay) {
_delay = delay;
}
// enforce a delay on admin transfers
function transferOwnership(address newOwner) public virtual override {
super.transferOwnership(newOwner); // this is onlyOwner
deadline = block.timestamp + delay;
}
function acceptOwnership() public virtual override {
require(deadline <= block.timestamp, "too early to access ownership");
delete deadline;
super.acceptOwnership();
}
// Owner is the admin
function hasRole(bytes32 role, address account)
public
view
virtual
override
returns (bool)
{
return role == DEFAULT_ADMIN_ROLE
? account == owner()
: super.hasRole(role, account);
}
function _grantRole(bytes32 role, address account) internal virtual override {
require(role != DEFAULT_ADMIN_ROLE, "Admin role is managed by owner");
super._grantRole(role, account);
}
function _revokeRole(bytes32 role, address account) internal virtual override {
require(role != DEFAULT_ADMIN_ROLE, "Admin role is managed by owner");
super._revokeRole(role, account);
}
} |
Are there cases where |
if is in the else part of an AFAIK, this would be sufficient if (role == DEFAULT_ADMIN_ROLE) {
require(
!_adminOwned || hasRole(role, account), // NOOP allowed
"AccessControlAdminRoles: Only current owner can be granted the role again."
);
_adminOwned = true;
} |
Also one thing, to finalize the transfer you must call |
Also (sorry for the many message), I think we should have internal functions to initiate the transfer ... so that devs can trigger that internally based on custom logic (signature ?) |
Also, not being able to transferAdmin it its already initiated + disable cancel when the timer is done means that if you set transferAdmin to an invalid address, and realize when its to late, you've "bricked" your contract. IMO we should:
|
I agree. Actually, that implementation with
This is not completely true, Defender will know that the address is the owner, but that that it has the corresponding rights. function acceptOwnership(address previousOwner) public virtual override {
require(deadline <= block.timestamp, "too early to access ownership");
delete deadline;
super.acceptOwnership(); // `pendingOwner == _msgSender()` checked here
_checkRole(previousOwner, DEFAULT_ADMIN_ROLE); // To check previousOwner is actually previousOwner
emit RoleGranted(DEFAULT_ADMIN_ROLE, previousOwner, _msgSender());
emit RoleGranted(DEFAULT_ADMIN_ROLE, _msgSender(), _msgSender());
} The same applies for Still, looks more readable to me but it adds extra logic just to comply with the
Glad you catch this! With the code as it is right now, no, there's no way. This is related to the
That's correct
That's also correct
Make sense to me, but I'd focus on reaching a agreement over the implementation first. I'll consider it for the next iteration.
I also dislike the
|
After a few attempts, this is the best I could come up with following overall comments: Code:abstract contract AccessControlAdminRules is AccessControl {
uint32 private immutable _delay;
address public pendingAdmin;
uint64 public pendingAdminTimer;
event AdminRoleChangeStarted(address indexed newOwner, uint64 timer);
constructor(uint32 initialDelay, address initialAdmin) {
_delay = initialDelay;
super.grantRole(DEFAULT_ADMIN_ROLE, initialAdmin);
}
// Owner can call with address(0) during the delay to cancel
function transferAdmin(address newAdmin) public virtual {
pendingAdminTimer = uint64(block.timestamp) + _delay;
pendingAdmin = newAdmin;
emit AdminRoleChangeStarted(pendingAdmin, pendingAdminTimer);
}
function acceptAdmin(address previousAdmin) public virtual {
require(
pendingAdmin == address(0) || _msgSender() == pendingAdmin,
"AccessControlAdminRules: caller is not the new owner"
);
_transferAdmin(previousAdmin);
}
function _grantRole(bytes32 role, address account) internal override {
if (role != DEFAULT_ADMIN_ROLE) super._grantRole(role, account);
}
function _revokeRole(bytes32 role, address account) internal override {
if (role != DEFAULT_ADMIN_ROLE) super._revokeRole(role, account);
}
function _transferAdmin(address previousAdmin) internal virtual {
require(
pendingAdminTimer > 0 && pendingAdminTimer < block.timestamp,
"AccessControlAdminRules: timer for admin transfer not set nor met"
);
_checkRole(DEFAULT_ADMIN_ROLE, previousAdmin);
delete pendingAdminTimer;
super._revokeRole(DEFAULT_ADMIN_ROLE, previousAdmin);
super._grantRole(DEFAULT_ADMIN_ROLE, pendingAdmin);
delete pendingAdmin;
}
} The proposed contract acts the same as an Although I like the simplicity of the
Aside from that opinion, I'd have to research these open questions:
|
this needs some protection so not everyone can call it!
This is not correct I'm not a big fan of having to provide
IMO we should revert of the role is |
If you do that and let the delay expire, then anyone can call |
Similar logic to OwnableTwoSteps. Naming can be discussed within a PR.
If you think the storage is worth it, we can go for it. I don't think so.
I'd say is similar confusion about calling a
I'd put this into the open questions.
See open questions |
The default admin in AccessControl is a very sensitive role and it requires special treatment. In the docs we recommend "extra precautions" but this refers mostly to off-chain operational security:
Is there something we can do at the contract level to encourage better security practices around the default admin role?
I am thinking we can have an extension of AccessControl that adds special rules for this role.
Some ideas I've gathered from @nchamo and @nventuro:
Related to #3593
The text was updated successfully, but these errors were encountered: