Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4374b53
Add AccessManagerEnumerable
ernestognw Nov 4, 2025
841e6bb
Add interface, docs and tests
ernestognw Nov 10, 2025
b4af192
Add changeset
ernestognw Nov 10, 2025
b9e7965
up
ernestognw Nov 10, 2025
2a0e900
Update list of functions by targets
ernestognw Nov 10, 2025
ab194a7
Add docs
ernestognw Nov 10, 2025
ba5a754
Merge branch 'master' into feat/access-manager-enumerable
ernestognw Nov 10, 2025
dad105c
Update doc references
ernestognw Nov 10, 2025
ece797a
up
ernestognw Nov 10, 2025
f199d64
Merge remote-tracking branch 'upstream/master' into feat/access-manag…
james-toussaint Dec 5, 2025
05379c1
Store target functions in `Bytes4Set`
james-toussaint Dec 5, 2025
b0ff8f2
Add _updateRoleTargetFunction
ernestognw Dec 10, 2025
6d54710
up
ernestognw Dec 10, 2025
c1e1c7e
Update .changeset/crazy-bears-flash.md
Amxx Dec 11, 2025
2da8182
update behavior
Amxx Dec 11, 2025
0605209
Remove IAccessManagerEnumerable
ernestognw Dec 11, 2025
a133fc0
up
ernestognw Dec 11, 2025
698f948
Run AccessManager tests for AccessManagerEnumerable
ernestognw Dec 11, 2025
a4c7b89
remove IAccessManagerEnumerable interface
Amxx Dec 11, 2025
903a7da
Move AccessControlEnumerable to mocks/docs
Amxx Dec 11, 2025
32fe6f4
remove tests, and update documentation
Amxx Dec 11, 2025
bf4a284
reset
Amxx Dec 11, 2025
0b981fd
re-enable tests for the enumerable variant of AccessManager
Amxx Dec 11, 2025
e0ca256
Merge branch 'master' into feat/access-manager-enumerable
Amxx Dec 11, 2025
b1c9eb9
revert changes to AccessManager tests
Amxx Dec 11, 2025
13f08cf
reset mock
Amxx Dec 11, 2025
d39400a
up
ernestognw Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions contracts/mocks/docs/AccessManagerEnumerable.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
52 changes: 47 additions & 5 deletions docs/modules/ROOT/pages/access-control.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down