Skip to content

Commit 289be11

Browse files
ernestognwjames-toussaintAmxx
authored
Add AccessManagerEnumerable (#6053)
Co-authored-by: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
1 parent 0e19973 commit 289be11

File tree

2 files changed

+208
-5
lines changed

2 files changed

+208
-5
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.24;
4+
5+
import {AccessManager} from "../../access/manager/AccessManager.sol";
6+
import {EnumerableSet} from "../../utils/structs/EnumerableSet.sol";
7+
8+
/**
9+
* @dev Extension of {AccessManager} that allows enumerating the members of each role
10+
* and the target functions each role is allowed to call.
11+
*
12+
* NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, the
13+
* {getRoleTargetFunctions} and {getRoleTargetFunctionCount} functions will return an empty array
14+
* and 0 respectively.
15+
*/
16+
abstract contract AccessManagerEnumerable is AccessManager {
17+
using EnumerableSet for EnumerableSet.AddressSet;
18+
using EnumerableSet for EnumerableSet.Bytes4Set;
19+
20+
mapping(uint64 roleId => EnumerableSet.AddressSet) private _roleMembers;
21+
mapping(uint64 roleId => mapping(address target => EnumerableSet.Bytes4Set)) private _roleTargetFunctions;
22+
23+
/**
24+
* @dev Returns the number of accounts that have `roleId`. Can be used
25+
* together with {getRoleMember} to enumerate all bearers of a role.
26+
*/
27+
function getRoleMemberCount(uint64 roleId) public view virtual returns (uint256) {
28+
return _roleMembers[roleId].length();
29+
}
30+
31+
/**
32+
* @dev Returns one of the accounts that have `roleId`. `index` must be a
33+
* value between 0 and {getRoleMemberCount}, non-inclusive.
34+
*
35+
* Role bearers are not sorted in any particular way, and their ordering may change at any point.
36+
*
37+
* WARNING: When using {getRoleMember} and {getRoleMemberCount}, make sure
38+
* you perform all queries on the same block. See the following
39+
* https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296[forum post]
40+
* for more information.
41+
*/
42+
function getRoleMember(uint64 roleId, uint256 index) public view virtual returns (address) {
43+
return _roleMembers[roleId].at(index);
44+
}
45+
46+
/**
47+
* @dev Returns a range of accounts that have `roleId`. `start` and `end` define the range bounds.
48+
* `start` is inclusive and `end` is exclusive.
49+
*
50+
* Role bearers are not sorted in any particular way, and their ordering may change at any point.
51+
*
52+
* It is not necessary to call {getRoleMemberCount} before calling this function. Using `start = 0` and
53+
* `end = type(uint256).max` will return every member of `roleId`.
54+
*
55+
* WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed
56+
* to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that
57+
* this function has an unbounded cost, and using it as part of a state-changing function may render the function
58+
* uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block.
59+
*/
60+
function getRoleMembers(uint64 roleId, uint256 start, uint256 end) public view virtual returns (address[] memory) {
61+
return _roleMembers[roleId].values(start, end);
62+
}
63+
64+
/**
65+
* @dev Returns the number of target function selectors that require `roleId` for the given `target`.
66+
* Can be used together with {getRoleTargetFunction} to enumerate all target functions for a role on a specific target.
67+
*
68+
* NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, passing {ADMIN_ROLE} as `roleId` will
69+
* return 0. See {_updateRoleTargetFunction} for more details.
70+
*/
71+
function getRoleTargetFunctionCount(uint64 roleId, address target) public view virtual returns (uint256) {
72+
return _roleTargetFunctions[roleId][target].length();
73+
}
74+
75+
/**
76+
* @dev Returns one of the target function selectors that require `roleId` for the given `target`.
77+
* `index` must be a value between 0 and {getRoleTargetFunctionCount}, non-inclusive.
78+
*
79+
* Target function selectors are not sorted in any particular way, and their ordering may change at any point.
80+
*
81+
* WARNING: When using {getRoleTargetFunction} and {getRoleTargetFunctionCount}, make sure
82+
* you perform all queries on the same block. See the following
83+
* https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296[forum post]
84+
* for more information.
85+
*/
86+
function getRoleTargetFunction(uint64 roleId, address target, uint256 index) public view virtual returns (bytes4) {
87+
return _roleTargetFunctions[roleId][target].at(index);
88+
}
89+
90+
/**
91+
* @dev Returns a range of target function selectors that require `roleId` for the given `target`.
92+
* `start` and `end` define the range bounds. `start` is inclusive and `end` is exclusive.
93+
*
94+
* Target function selectors are not sorted in any particular way, and their ordering may change at any point.
95+
*
96+
* It is not necessary to call {getRoleTargetFunctionCount} before calling this function. Using `start = 0` and
97+
* `end = type(uint256).max` will return every function selector that `roleId` is allowed to call on `target`.
98+
*
99+
* WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed
100+
* to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that
101+
* this function has an unbounded cost, and using it as part of a state-changing function may render the function
102+
* uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block.
103+
*
104+
* NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, passing {ADMIN_ROLE} as `roleId` will
105+
* return an empty array. See {_updateRoleTargetFunction} for more details.
106+
*/
107+
function getRoleTargetFunctions(
108+
uint64 roleId,
109+
address target,
110+
uint256 start,
111+
uint256 end
112+
) public view virtual returns (bytes4[] memory) {
113+
return _roleTargetFunctions[roleId][target].values(start, end);
114+
}
115+
116+
/// @dev See {AccessManager-_grantRole}. Adds the account to the role members set.
117+
function _grantRole(
118+
uint64 roleId,
119+
address account,
120+
uint32 grantDelay,
121+
uint32 executionDelay
122+
) internal virtual override returns (bool) {
123+
bool granted = super._grantRole(roleId, account, grantDelay, executionDelay);
124+
if (granted) {
125+
_roleMembers[roleId].add(account);
126+
}
127+
return granted;
128+
}
129+
130+
/// @dev See {AccessManager-_revokeRole}. Removes the account from the role members set.
131+
function _revokeRole(uint64 roleId, address account) internal virtual override returns (bool) {
132+
bool revoked = super._revokeRole(roleId, account);
133+
if (revoked) {
134+
_roleMembers[roleId].remove(account);
135+
}
136+
return revoked;
137+
}
138+
139+
/**
140+
* @dev See {AccessManager-_setTargetFunctionRole}. Adds the selector to the role target functions set.
141+
*
142+
* NOTE: This function does not track function selectors for the {ADMIN_ROLE}, since exhaustively tracking
143+
* all restricted/admin functions is impractical (by default, all restricted functions are assigned to {ADMIN_ROLE}).
144+
* Therefore, roles assigned as {ADMIN_ROLE} will not have their selectors included in this extension's tracking.
145+
*/
146+
function _setTargetFunctionRole(address target, bytes4 selector, uint64 roleId) internal virtual override {
147+
// cache old role ID
148+
uint64 oldRoleId = getTargetFunctionRole(target, selector);
149+
150+
// call super
151+
super._setTargetFunctionRole(target, selector, roleId);
152+
153+
// update enumerable sets
154+
if (oldRoleId != ADMIN_ROLE) {
155+
_roleTargetFunctions[oldRoleId][target].remove(selector);
156+
}
157+
if (roleId != ADMIN_ROLE) {
158+
_roleTargetFunctions[roleId][target].add(selector);
159+
}
160+
}
161+
}

docs/modules/ROOT/pages/access-control.adoc

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ The base `AccessControl` contract provides role-based access control, but it doe
9696

9797
This contract uses `EnumerableSet` internally and provides the following functions:
9898

99-
* {AccessControlEnumerable-getRoleMemberCount}
100-
* {AccessControlEnumerable-getRoleMember}
99+
* xref:api:access.adoc#AccessControlEnumerable-getRoleMemberCount-bytes32-[`getRoleMemberCount`]
100+
* xref:api:access.adoc#AccessControlEnumerable-getRoleMember-bytes32-uint256-[`getRoleMember`]
101+
* xref:api:access.adoc#AccessControlEnumerable-getRoleMembers-bytes32-[`getRoleMembers`]
101102

102103
These can be used to iterate over the accounts that have been granted a role:
103104

@@ -160,7 +161,7 @@ image::access-manager.svg[AccessManager]
160161
The AccessManager is designed around the concept of role and target functions:
161162

162163
* 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.
163-
* 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).
164+
* 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).
164165

165166
For a call to be authorized, the caller must bear the role that is assigned to the current target function (contract address + function selector).
166167

@@ -209,11 +210,11 @@ const HOUR = 60 * 60;
209210
const GRANT_DELAY = 24 * HOUR;
210211
const EXECUTION_DELAY = 5 * HOUR;
211212
const ACCOUNT = "0x...";
212-
213+
213214
await manager.connect(initialAdmin).setGrantDelay(MINTER, GRANT_DELAY);
214215

215216
// The role will go into effect after the GRANT_DELAY passes
216-
await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY);
217+
await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY);
217218
```
218219

219220
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:
272273
* Closing or opening a target via xref:api:access.adoc#AccessManager-setTargetClosed-address-bool-[`setTargetClosed`].
273274
* Changing permissions of whether a role can call a target function with xref:api:access.adoc#AccessManager-setTargetFunctionRole-address-bytes4---uint64-[`setTargetFunctionRole`].
274275

276+
=== Manager Enumerability
277+
278+
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.
279+
280+
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.
281+
282+
If on-chain enumeration is required, it can be added implemented on top of the existing logic:
283+
284+
```solidity
285+
include::api:example$AccessManagerEnumerable.sol[]
286+
```
287+
288+
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.
289+
290+
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:
291+
292+
```javascript
293+
// Enumerate role members
294+
const minterCount = await accessManager.getRoleMemberCount(MINTER_ROLE);
295+
296+
const members = [];
297+
for (let i = 0; i < minterCount; ++i) {
298+
members.push(await accessManager.getRoleMember(MINTER_ROLE, i));
299+
}
300+
301+
// Or get all members at once
302+
const allMembers = await accessManager.getRoleMembers(MINTER_ROLE, 0, ethers.MaxUint256);
303+
304+
// Enumerate target functions for a role
305+
const target = await myToken.getAddress();
306+
const functionCount = await accessManager.getRoleTargetFunctionCount(MINTER_ROLE, target);
307+
308+
const functions = [];
309+
for (let i = 0; i < functionCount; ++i) {
310+
functions.push(await accessManager.getRoleTargetFunction(MINTER_ROLE, target, i));
311+
}
312+
313+
// Or get all functions at once
314+
const allFunctions = await accessManager.getRoleTargetFunctions(MINTER_ROLE, target, 0, ethers.MaxUint256);
315+
```
316+
275317
=== Using with Ownable
276318

277319
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.

0 commit comments

Comments
 (0)