diff --git a/contracts/mocks/docs/AccessManagerEnumerable.sol b/contracts/mocks/docs/AccessManagerEnumerable.sol new file mode 100644 index 00000000000..629bb2e6c42 --- /dev/null +++ b/contracts/mocks/docs/AccessManagerEnumerable.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {AccessManager} from "../../access/manager/AccessManager.sol"; +import {EnumerableSet} from "../../utils/structs/EnumerableSet.sol"; + +/** + * @dev Extension of {AccessManager} that allows enumerating the members of each role + * and the target functions each role is allowed to call. + * + * NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, the + * {getRoleTargetFunctions} and {getRoleTargetFunctionCount} functions will return an empty array + * and 0 respectively. + */ +abstract contract AccessManagerEnumerable is AccessManager { + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes4Set; + + mapping(uint64 roleId => EnumerableSet.AddressSet) private _roleMembers; + mapping(uint64 roleId => mapping(address target => EnumerableSet.Bytes4Set)) private _roleTargetFunctions; + + /** + * @dev Returns the number of accounts that have `roleId`. Can be used + * together with {getRoleMember} to enumerate all bearers of a role. + */ + function getRoleMemberCount(uint64 roleId) public view virtual returns (uint256) { + return _roleMembers[roleId].length(); + } + + /** + * @dev Returns one of the accounts that have `roleId`. `index` must be a + * value between 0 and {getRoleMemberCount}, non-inclusive. + * + * Role bearers are not sorted in any particular way, and their ordering may change at any point. + * + * WARNING: When using {getRoleMember} and {getRoleMemberCount}, make sure + * you perform all queries on the same block. See the following + * https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296[forum post] + * for more information. + */ + function getRoleMember(uint64 roleId, uint256 index) public view virtual returns (address) { + return _roleMembers[roleId].at(index); + } + + /** + * @dev Returns a range of accounts that have `roleId`. `start` and `end` define the range bounds. + * `start` is inclusive and `end` is exclusive. + * + * Role bearers are not sorted in any particular way, and their ordering may change at any point. + * + * It is not necessary to call {getRoleMemberCount} before calling this function. Using `start = 0` and + * `end = type(uint256).max` will return every member of `roleId`. + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function getRoleMembers(uint64 roleId, uint256 start, uint256 end) public view virtual returns (address[] memory) { + return _roleMembers[roleId].values(start, end); + } + + /** + * @dev Returns the number of target function selectors that require `roleId` for the given `target`. + * Can be used together with {getRoleTargetFunction} to enumerate all target functions for a role on a specific target. + * + * NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, passing {ADMIN_ROLE} as `roleId` will + * return 0. See {_updateRoleTargetFunction} for more details. + */ + function getRoleTargetFunctionCount(uint64 roleId, address target) public view virtual returns (uint256) { + return _roleTargetFunctions[roleId][target].length(); + } + + /** + * @dev Returns one of the target function selectors that require `roleId` for the given `target`. + * `index` must be a value between 0 and {getRoleTargetFunctionCount}, non-inclusive. + * + * Target function selectors are not sorted in any particular way, and their ordering may change at any point. + * + * WARNING: When using {getRoleTargetFunction} and {getRoleTargetFunctionCount}, make sure + * you perform all queries on the same block. See the following + * https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296[forum post] + * for more information. + */ + function getRoleTargetFunction(uint64 roleId, address target, uint256 index) public view virtual returns (bytes4) { + return _roleTargetFunctions[roleId][target].at(index); + } + + /** + * @dev Returns a range of target function selectors that require `roleId` for the given `target`. + * `start` and `end` define the range bounds. `start` is inclusive and `end` is exclusive. + * + * Target function selectors are not sorted in any particular way, and their ordering may change at any point. + * + * It is not necessary to call {getRoleTargetFunctionCount} before calling this function. Using `start = 0` and + * `end = type(uint256).max` will return every function selector that `roleId` is allowed to call on `target`. + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + * + * NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, passing {ADMIN_ROLE} as `roleId` will + * return an empty array. See {_updateRoleTargetFunction} for more details. + */ + function getRoleTargetFunctions( + uint64 roleId, + address target, + uint256 start, + uint256 end + ) public view virtual returns (bytes4[] memory) { + return _roleTargetFunctions[roleId][target].values(start, end); + } + + /// @dev See {AccessManager-_grantRole}. Adds the account to the role members set. + function _grantRole( + uint64 roleId, + address account, + uint32 grantDelay, + uint32 executionDelay + ) internal virtual override returns (bool) { + bool granted = super._grantRole(roleId, account, grantDelay, executionDelay); + if (granted) { + _roleMembers[roleId].add(account); + } + return granted; + } + + /// @dev See {AccessManager-_revokeRole}. Removes the account from the role members set. + function _revokeRole(uint64 roleId, address account) internal virtual override returns (bool) { + bool revoked = super._revokeRole(roleId, account); + if (revoked) { + _roleMembers[roleId].remove(account); + } + return revoked; + } + + /** + * @dev See {AccessManager-_setTargetFunctionRole}. Adds the selector to the role target functions set. + * + * NOTE: This function does not track function selectors for the {ADMIN_ROLE}, since exhaustively tracking + * all restricted/admin functions is impractical (by default, all restricted functions are assigned to {ADMIN_ROLE}). + * Therefore, roles assigned as {ADMIN_ROLE} will not have their selectors included in this extension's tracking. + */ + function _setTargetFunctionRole(address target, bytes4 selector, uint64 roleId) internal virtual override { + // cache old role ID + uint64 oldRoleId = getTargetFunctionRole(target, selector); + + // call super + super._setTargetFunctionRole(target, selector, roleId); + + // update enumerable sets + if (oldRoleId != ADMIN_ROLE) { + _roleTargetFunctions[oldRoleId][target].remove(selector); + } + if (roleId != ADMIN_ROLE) { + _roleTargetFunctions[roleId][target].add(selector); + } + } +} diff --git a/docs/modules/ROOT/pages/access-control.adoc b/docs/modules/ROOT/pages/access-control.adoc index d8b8cdb78c1..43e08d22aa3 100644 --- a/docs/modules/ROOT/pages/access-control.adoc +++ b/docs/modules/ROOT/pages/access-control.adoc @@ -96,8 +96,9 @@ The base `AccessControl` contract provides role-based access control, but it doe This contract uses `EnumerableSet` internally and provides the following functions: -* {AccessControlEnumerable-getRoleMemberCount} -* {AccessControlEnumerable-getRoleMember} +* xref:api:access.adoc#AccessControlEnumerable-getRoleMemberCount-bytes32-[`getRoleMemberCount`] +* xref:api:access.adoc#AccessControlEnumerable-getRoleMember-bytes32-uint256-[`getRoleMember`] +* xref:api:access.adoc#AccessControlEnumerable-getRoleMembers-bytes32-[`getRoleMembers`] These can be used to iterate over the accounts that have been granted a role: @@ -160,7 +161,7 @@ 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). +* 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). @@ -209,11 +210,11 @@ 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); +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. @@ -272,6 +273,47 @@ The delayed admin actions are: * 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`]. +=== Manager Enumerability + +Similar to `AccessControl`, accounts might be granted and revoked roles dynamically in an `AccessManager`, making it challenging to determine which accounts hold a particular role at any given time. This capability is essential for proving certain properties about a system, such as verifying that an administrative role is held by a multisig or DAO, or that a certain role has been completely removed to disable associated functionality. + +The base `AccessManager` contract provides comprehensive role-based access control but does not support on-chain enumeration of role members or target function permissions by default. To track which accounts hold roles and which functions are assigned to roles, you should rely on the xref:api:access.adoc#AccessManager-RoleGranted-uint64-address-uint32-uint48-bool-[RoleGranted], xref:api:access.adoc#AccessManager-RoleRevoked-uint64-address-[RoleRevoked], and xref:api:access.adoc#AccessManager-TargetFunctionRoleUpdated-address-bytes4-uint64-[TargetFunctionRoleUpdated] events, which can be processed off-chain. + +If on-chain enumeration is required, it can be added implemented on top of the existing logic: + +```solidity +include::api:example$AccessManagerEnumerable.sol[] +``` + +NOTE: The enumerable example only enumerates members of a role and functions that each role can call. Yet, it's possible to enumerate roles active (i.e. roles granted to at least 1 member), guardians and admins. + +This adds function that can be queried to iterate over the accounts that have been granted a role and the functions that a role is allowed to call on specific targets: + +```javascript +// Enumerate role members +const minterCount = await accessManager.getRoleMemberCount(MINTER_ROLE); + +const members = []; +for (let i = 0; i < minterCount; ++i) { + members.push(await accessManager.getRoleMember(MINTER_ROLE, i)); +} + +// Or get all members at once +const allMembers = await accessManager.getRoleMembers(MINTER_ROLE, 0, ethers.MaxUint256); + +// Enumerate target functions for a role +const target = await myToken.getAddress(); +const functionCount = await accessManager.getRoleTargetFunctionCount(MINTER_ROLE, target); + +const functions = []; +for (let i = 0; i < functionCount; ++i) { + functions.push(await accessManager.getRoleTargetFunction(MINTER_ROLE, target, i)); +} + +// Or get all functions at once +const allFunctions = await accessManager.getRoleTargetFunctions(MINTER_ROLE, target, 0, ethers.MaxUint256); +``` + === 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.