From 83330a6e4c8efeded3ea2b8d6aae5ac2ffc18555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Thu, 9 Nov 2023 16:03:11 +0000 Subject: [PATCH] Add `AccessManager` guide (#4691) (#4724) Co-authored-by: Hadrien Croubois Co-authored-by: Eric Lau Co-authored-by: Zack Reneau-Wedeen --- .../AccessControlERC20MintBase.sol | 25 ++ .../AccessControlERC20MintMissing.sol | 24 ++ .../AccessControlERC20MintOnlyRole.sol | 23 ++ .../AccessManagedERC20MintBase.sol | 16 ++ .../MyContractOwnable.sol | 2 +- .../ROOT/images/access-control-multiple.svg | 97 ++++++++ .../ROOT/images/access-manager-functions.svg | 47 ++++ docs/modules/ROOT/images/access-manager.svg | 99 ++++++++ docs/modules/ROOT/pages/access-control.adoc | 229 ++++++++++++------ 9 files changed, 487 insertions(+), 75 deletions(-) create mode 100644 contracts/mocks/docs/access-control/AccessControlERC20MintBase.sol create mode 100644 contracts/mocks/docs/access-control/AccessControlERC20MintMissing.sol create mode 100644 contracts/mocks/docs/access-control/AccessControlERC20MintOnlyRole.sol create mode 100644 contracts/mocks/docs/access-control/AccessManagedERC20MintBase.sol rename contracts/mocks/docs/{ => access-control}/MyContractOwnable.sol (86%) create mode 100644 docs/modules/ROOT/images/access-control-multiple.svg create mode 100644 docs/modules/ROOT/images/access-manager-functions.svg create mode 100644 docs/modules/ROOT/images/access-manager.svg diff --git a/contracts/mocks/docs/access-control/AccessControlERC20MintBase.sol b/contracts/mocks/docs/access-control/AccessControlERC20MintBase.sol new file mode 100644 index 00000000000..25139cbc478 --- /dev/null +++ b/contracts/mocks/docs/access-control/AccessControlERC20MintBase.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "../../../access/AccessControl.sol"; +import {ERC20} from "../../../token/ERC20/ERC20.sol"; + +contract AccessControlERC20MintBase is ERC20, AccessControl { + // Create a new role identifier for the minter role + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + error CallerNotMinter(address caller); + + constructor(address minter) ERC20("MyToken", "TKN") { + // Grant the minter role to a specified account + _grantRole(MINTER_ROLE, minter); + } + + function mint(address to, uint256 amount) public { + // Check that the calling account has the minter role + if (!hasRole(MINTER_ROLE, msg.sender)) { + revert CallerNotMinter(msg.sender); + } + _mint(to, amount); + } +} diff --git a/contracts/mocks/docs/access-control/AccessControlERC20MintMissing.sol b/contracts/mocks/docs/access-control/AccessControlERC20MintMissing.sol new file mode 100644 index 00000000000..46002fd047f --- /dev/null +++ b/contracts/mocks/docs/access-control/AccessControlERC20MintMissing.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "../../../access/AccessControl.sol"; +import {ERC20} from "../../../token/ERC20/ERC20.sol"; + +contract AccessControlERC20MintMissing is ERC20, AccessControl { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); + + constructor() ERC20("MyToken", "TKN") { + // Grant the contract deployer the default admin role: it will be able + // to grant and revoke any roles + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) { + _burn(from, amount); + } +} diff --git a/contracts/mocks/docs/access-control/AccessControlERC20MintOnlyRole.sol b/contracts/mocks/docs/access-control/AccessControlERC20MintOnlyRole.sol new file mode 100644 index 00000000000..a71060ad896 --- /dev/null +++ b/contracts/mocks/docs/access-control/AccessControlERC20MintOnlyRole.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessControl} from "../../../access/AccessControl.sol"; +import {ERC20} from "../../../token/ERC20/ERC20.sol"; + +contract AccessControlERC20Mint is ERC20, AccessControl { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); + + constructor(address minter, address burner) ERC20("MyToken", "TKN") { + _grantRole(MINTER_ROLE, minter); + _grantRole(BURNER_ROLE, burner); + } + + function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) { + _burn(from, amount); + } +} diff --git a/contracts/mocks/docs/access-control/AccessManagedERC20MintBase.sol b/contracts/mocks/docs/access-control/AccessManagedERC20MintBase.sol new file mode 100644 index 00000000000..02ae00a1ae7 --- /dev/null +++ b/contracts/mocks/docs/access-control/AccessManagedERC20MintBase.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {AccessManaged} from "../../../access/manager/AccessManaged.sol"; +import {ERC20} from "../../../token/ERC20/ERC20.sol"; + +contract AccessManagedERC20Mint is ERC20, AccessManaged { + constructor(address manager) ERC20("MyToken", "TKN") AccessManaged(manager) {} + + // Minting is restricted according to the manager rules for this function. + // The function is identified by its selector: 0x40c10f19. + // Calculated with bytes4(keccak256('mint(address,uint256)')) + function mint(address to, uint256 amount) public restricted { + _mint(to, amount); + } +} diff --git a/contracts/mocks/docs/MyContractOwnable.sol b/contracts/mocks/docs/access-control/MyContractOwnable.sol similarity index 86% rename from contracts/mocks/docs/MyContractOwnable.sol rename to contracts/mocks/docs/access-control/MyContractOwnable.sol index 01847c0362e..0dfc804f256 100644 --- a/contracts/mocks/docs/MyContractOwnable.sol +++ b/contracts/mocks/docs/access-control/MyContractOwnable.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; -import {Ownable} from "../../access/Ownable.sol"; +import {Ownable} from "../../../access/Ownable.sol"; contract MyContract is Ownable { constructor(address initialOwner) Ownable(initialOwner) {} diff --git a/docs/modules/ROOT/images/access-control-multiple.svg b/docs/modules/ROOT/images/access-control-multiple.svg new file mode 100644 index 00000000000..0314e09e4eb --- /dev/null +++ b/docs/modules/ROOT/images/access-control-multiple.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/images/access-manager-functions.svg b/docs/modules/ROOT/images/access-manager-functions.svg new file mode 100644 index 00000000000..dbbf04179bd --- /dev/null +++ b/docs/modules/ROOT/images/access-manager-functions.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/images/access-manager.svg b/docs/modules/ROOT/images/access-manager.svg new file mode 100644 index 00000000000..12f91bae796 --- /dev/null +++ b/docs/modules/ROOT/images/access-manager.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/pages/access-control.adoc b/docs/modules/ROOT/pages/access-control.adoc index 2a977a2b188..5b334c2fe9e 100644 --- a/docs/modules/ROOT/pages/access-control.adoc +++ b/docs/modules/ROOT/pages/access-control.adoc @@ -10,10 +10,10 @@ The most common and basic form of access control is the concept of _ownership_: OpenZeppelin Contracts provides xref:api:access.adoc#Ownable[`Ownable`] for implementing ownership in your contracts. ```solidity -include::api:example$MyContractOwnable.sol[] +include::api:example$access-control/MyContractOwnable.sol[] ``` -By default, the xref:api:access.adoc#Ownable-owner--[`owner`] of an `Ownable` contract is the account that deployed it, which is usually exactly what you want. +At deployment, the xref:api:access.adoc#Ownable-owner--[`owner`] of an `Ownable` contract is set to the provided `initialOwner` parameter. Ownable also lets you: @@ -41,32 +41,11 @@ Most software uses access control systems that are role-based: some users are re OpenZeppelin Contracts provides xref:api:access.adoc#AccessControl[`AccessControl`] for implementing role-based access control. Its usage is straightforward: for each role that you want to define, you will create a new _role identifier_ that is used to grant, revoke, and check if an account has that role. -Here's a simple example of using `AccessControl` in an xref:tokens.adoc#ERC20[`ERC20` token] to define a 'minter' role, which allows accounts that have it create new tokens: +Here's a simple example of using `AccessControl` in an xref:erc20.adoc[`ERC20` token] to define a 'minter' role, which allows accounts that have it create new tokens: [source,solidity] ---- -// contracts/MyToken.sol -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract MyToken is ERC20, AccessControl { - // Create a new role identifier for the minter role - bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - - constructor(address minter) ERC20("MyToken", "TKN") { - // Grant the minter role to a specified account - _grantRole(MINTER_ROLE, minter); - } - - function mint(address to, uint256 amount) public { - // Check that the calling account has the minter role - require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter"); - _mint(to, amount); - } -} +include::api:example$access-control/AccessControlERC20MintBase.sol[] ---- NOTE: Make sure you fully understand how xref:api:access.adoc#AccessControl[`AccessControl`] works before using it on your system, or copy-pasting the examples from this guide. @@ -77,30 +56,7 @@ Let's augment our ERC20 token example by also defining a 'burner' role, which le [source,solidity] ---- -// contracts/MyToken.sol -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract MyToken is ERC20, AccessControl { - bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); - - constructor(address minter, address burner) ERC20("MyToken", "TKN") { - _grantRole(MINTER_ROLE, minter); - _grantRole(BURNER_ROLE, burner); - } - - function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { - _mint(to, amount); - } - - function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) { - _burn(from, amount); - } -} +include::api:example$access-control/AccessControlERC20MintOnlyRole.sol[] ---- So clean! By splitting concerns this way, more granular levels of permission may be implemented than were possible with the simpler _ownership_ approach to access control. Limiting what each component of a system is able to do is known as the https://en.wikipedia.org/wiki/Principle_of_least_privilege[principle of least privilege], and is a good security practice. Note that each account may still have more than one role, if so desired. @@ -122,31 +78,7 @@ Let's take a look at the ERC20 token example, this time taking advantage of the [source,solidity] ---- -// contracts/MyToken.sol -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract MyToken is ERC20, AccessControl { - bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); - - constructor() ERC20("MyToken", "TKN") { - // Grant the contract deployer the default admin role: it will be able - // to grant and revoke any roles - _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); - } - - function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { - _mint(to, amount); - } - - function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) { - _burn(from, amount); - } -} +include::api:example$access-control/AccessControlERC20MintMissing.sol[] ---- Note that, unlike the previous examples, no accounts are granted the 'minter' or 'burner' roles. However, because those roles' admin role is the default admin role, and _that_ role was granted to `msg.sender`, that same account can call `grantRole` to give minting or burning permission, and `revokeRole` to remove it. @@ -202,3 +134,152 @@ TIP: A recommended configuration is to grant both roles to a secure governance c Operations executed by the xref:api:governance.adoc#TimelockController[`TimelockController`] are not subject to a fixed delay but rather a minimum delay. Some major updates might call for a longer delay. For example, if a delay of just a few days might be sufficient for users to audit a minting operation, it makes sense to use a delay of a few weeks, or even a few months, when scheduling a smart contract upgrade. The minimum delay (accessible through the xref:api:governance.adoc#TimelockController-getMinDelay--[`getMinDelay`] method) can be updated by calling the xref:api:governance.adoc#TimelockController-updateDelay-uint256-[`updateDelay`] function. Bear in mind that access to this function is only accessible by the timelock itself, meaning this maintenance operation has to go through the timelock itself. + +[[access-management]] +== Access Management + +For a system of contracts, better integrated role management can be achieved with an xref:api:access.adoc#AccessManager[`AccessManager`] instance. Instead of managing each contract's permission separately, AccessManager stores all the permissions in a single contract, making your protocol easier to audit and maintain. + +Although xref:api:access.adoc#AccessControl[`AccessControl`] offers a more dynamic solution for adding permissions to your contracts than Ownable, decentralized protocols tend to become more complex after integrating new contract instances and requires you to keep track of permissions separately in each contract. This increases the complexity of permissions management and monitoring across the system. + +image::access-control-multiple.svg[Access Control multiple] + +Protocols managing permissions in production systems often require more integrated alternatives to fragmented permissions through multiple `AccessControl` instances. + +image::access-manager.svg[AccessManager] + +The AccessManager is designed around the concept of role and target functions: + +* Roles are granted to accounts (addresses) following a many-to-many approach for flexibility. This means that each user can have one or multiple roles and multiple users can have the same role. +* Access to a restricted target function is limited to one role. A target function is defined by one https://docs.soliditylang.org/en/v0.8.20/abi-spec.html#function-selector[function selector] on one contract (called target). + +For a call to be authorized, the caller must bear the role that is assigned to the current target function (contract address + function selector). + +image::access-manager-functions.svg[AccessManager functions] + +=== Using `AccessManager` + +OpenZeppelin Contracts provides xref:api:access.adoc#AccessManager[`AccessManager`] for managing roles across any number of contracts. The `AccessManager` itself is a contract that can be deployed and used out of the box. It sets an initial admin in the constructor who will be allowed to perform management operations. + +In order to restrict access to some functions of your contract, you should inherit from the xref:api:access.adoc#AccessManaged[`AccessManaged`] contract provided along with the manager. This provides the `restricted` modifier that can be used to protect any externally facing function. Note that you will have to specify the address of the AccessManager instance (xref:api:access.adoc#AccessManaged-constructor-address-[`initialAuthority`]) in the constructor so the `restricted` modifier knows which manager to use for checking permissions. + +Here's a simple example of an xref:tokens.adoc#ERC20[`ERC20` token] that defines a `mint` function that is restricted by an xref:api:access.adoc#AccessManager[`AccessManager`]: + +```solidity +include::api:example$access-control/AccessManagedERC20MintBase.sol[] +``` + +NOTE: Make sure you fully understand how xref:api:access.adoc#AccessManager[`AccessManager`] works before using it or copy-pasting the examples from this guide. + +Once the managed contract has been deployed, it is now under the manager's control. The initial admin can then assign the minter role to an address and also allow the role to call the `mint` function. For example, this is demonstrated in the following Javascript code using Ethers.js: + +```javascript +// const target = ...; +// const user = ...; +const MINTER = 42n; // Roles are uint64 (0 is reserved for the ADMIN_ROLE) + +// Grant the minter role with no execution delay +await manager.grantRole(MINTER, user, 0); + +// Allow the minter role to call the function selector +// corresponding to the mint function +await manager.setTargetFunctionRole( + target, + ['0x40c10f19'], // bytes4(keccak256('mint(address,uint256)')) + MINTER +); + +Even though each role has its own list of function permissions, each role member (`address`) has an execution delay that will dictate how long the account should wait to execute a function that requires its role. Delayed operations must have the xref:api:access.adoc#AccessManager-schedule-address-bytes-uint48-[`schedule`] function called on them first in the AccessManager before they can be executed, either by calling to the target function or using the AccessManager's xref:api:access.adoc#AccessManager-execute-address-bytes-[`execute`] function. + +Additionally, roles can have a granting delay that prevents adding members immediately. The AccessManager admins can set this grant delay as follows: + +```javascript +const HOUR = 60 * 60; + +const GRANT_DELAY = 24 * HOUR; +const EXECUTION_DELAY = 5 * HOUR; +const ACCOUNT = "0x..."; + +await manager.connect(initialAdmin).setGrantDelay(MINTER, GRANT_DELAY); + +// The role will go into effect after the GRANT_DELAY passes +await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY); +``` + +Note that roles do not define a name. As opposed to the xref:api:access.adoc#AccessControl[`AccessControl`] case, roles are identified as numeric values instead of being hardcoded in the contract as `bytes32` values. It is still possible to allow for tooling discovery (e.g. for role exploration) using role labeling with the xref:api:access.adoc#AccessManager-labelRole-uint64-string-[`labelRole`] function. + +```javascript +await manager.labelRole(MINTER, "MINTER"); +``` + +Given the admins of the `AccessManaged` can modify all of its permissions, it's recommended to keep only a single admin address secured under a multisig or governance layer. To achieve this, it is possible for the initial admin to set up all the required permissions, targets, and functions, assign a new admin, and finally renounce its admin role. + +For improved incident response coordination, the manager includes a mode where administrators can completely close a target contract. When closed, all calls to restricted target functions in a target contract will revert. + +Closing and opening contracts don't alter any of their settings, neither permissions nor delays. Particularly, the roles required for calling specific target functions are not modified. + +This mode is useful for incident response operations that require temporarily shutting down a contract in order to evaluate emergencies and reconfigure permissions. + +```javascript +const target = await myToken.getAddress(); + +// Token's `restricted` functions closed +await manager.setTargetClosed(target, true); + +// Token's `restricted` functions open +await manager.setTargetClosed(target, false); +``` + +WARNING: Even if an `AccessManager` defines permissions for a target function, these won't be applied if the managed contract instance is not using the xref:api:access.adoc#AccessManaged-restricted--[`restricted`] modifier for that function, or if its manager is a different one. + +=== Role Admins and Guardians + +An important aspect of the AccessControl contract is that roles aren't granted nor revoked by role members. Instead, it relies on the concept of a role admin for granting and revoking. + +In the case of the `AccessManager`, the same rule applies and only the role's admins are able to call xref:api:access.adoc#AccessManager-grantRole-uint64-address-uint32-[grant] and xref:api:access.adoc#AccessManager-revokeRole-uint64-address-[revoke] functions. Note that calling these functions will be subject to the execution delay that the executing role admin has. + +Additionally, the `AccessManager` stores a _guardian_ as an extra protection for each role. This guardian has the ability to cancel operations that have been scheduled by any role member with an execution delay. Consider that a role will have its initial admin and guardian default to the `ADMIN_ROLE` (`0`). + +IMPORTANT: Be careful with the members of `ADMIN_ROLE`, since it acts as the default admin and guardian for every role. A misbehaved guardian can cancel operations at will, affecting the AccessManager's operation. + +=== Manager configuration + +The `AccessManager` provides a built-in interface for configuring permission settings that can be accessed by its `ADMIN_ROLE` members. + +This configuration interface includes the following functions: + +* Add a label to a role using the xref:api:access.adoc#AccessManager-labelRole-uint64-string-[`labelRole`] function. +* Assign the admin and guardian of a role with xref:api:access.adoc#AccessManager-setRoleAdmin-uint64-uint64-[`setRoleAdmin`] and xref:api:access.adoc#AccessManager-setRoleGuardian-uint64-uint64-[`setRoleGuardian`]. +* Set each role's grant delay via xref:api:access.adoc#AccessManager-setGrantDelay-uint64-uint32-[`setGrantDelay`]. + +As an admin, some actions will require a delay. Similar to each member's execution delay, some admin operations require waiting for execution and should follow the xref:api:access.adoc#AccessManager-schedule-address-bytes-uint48-[`schedule`] and xref:api:access.adoc#AccessManager-execute-address-bytes-[`execute`] workflow. + +More specifically, these delayed functions are those for configuring the settings of a specific target contract. The delay applied to these functions can be adjusted by the manager admins with xref:api:access.adoc#AccessManager-setTargetAdminDelay-address-uint32-[`setTargetAdminDelay`]. + +The delayed admin actions are: + +* Updating an `AccessManaged` contract xref:api:access.adoc#AccessManaged-authority--[authority] using xref:api:access.adoc#AccessManager-updateAuthority-address-address-[`updateAuthority`]. +* Closing or opening a target via xref:api:access.adoc#AccessManager-setTargetClosed-address-bool-[`setTargetClosed`]. +* Changing permissions of whether a role can call a target function with xref:api:access.adoc#AccessManager-setTargetFunctionRole-address-bytes4---uint64-[`setTargetFunctionRole`]. + +=== Using with Ownable + +Contracts already inheriting from xref:api:access.adoc#Ownable[`Ownable`] can migrate to AccessManager by transferring ownership to the manager. After that, all calls to functions with the `onlyOwner` modifier should be called through the manager's xref:api:access.adoc#AccessManager-execute-address-bytes-[`execute`] function, even if the caller doesn't require a delay. + +```javascript +await ownable.connect(owner).transferOwnership(accessManager); +``` + +=== Using with AccessControl + +For systems already using xref:api:access.adoc#AccessControl[`AccessControl`], the `DEFAULT_ADMIN_ROLE` can be granted to the `AccessManager` after revoking every other role. Subsequent calls should be made through the manager's xref:api:access.adoc#AccessManager-execute-address-bytes-[`execute`] method, similar to the Ownable case. + +```javascript +// Revoke old roles +await accessControl.connect(admin).revokeRoke(MINTER_ROLE, account); + +// Grant the admin role to the access manager +await accessControl.connect(admin).grantRole(DEFAULT_ADMIN_ROLE, accessManager); + +await accessControl.connect(admin).renounceRole(DEFAULT_ADMIN_ROLE, admin); +```