diff --git a/.changeset/crazy-bears-flash.md b/.changeset/crazy-bears-flash.md new file mode 100644 index 00000000000..b668edb0cc2 --- /dev/null +++ b/.changeset/crazy-bears-flash.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +Add `AccessManagerEnumerable`, an extension of `AccessManager` that allows enumerating the members of each role and the target functions each role is allowed to call. diff --git a/contracts/access/README.adoc b/contracts/access/README.adoc index b89865b2c17..ec837cdbd90 100644 --- a/contracts/access/README.adoc +++ b/contracts/access/README.adoc @@ -6,6 +6,7 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/ This directory provides ways to restrict who can access the functions of a contract or when they can do it. - {AccessManager} is a full-fledged access control solution for smart contract systems. Allows creating and assigning multiple hierarchical roles with execution delays for each account across various contracts. +- {AccessManagerEnumerable} is an extension to {AccessManager} that enumerates role members and target functions each role can call. - {AccessManaged} delegates its access control to an authority that dictates the permissions of the managed contract. It's compatible with an AccessManager as an authority. - {AccessControl} provides a per-contract role based access control mechanism. Multiple hierarchical roles can be created and assigned each to multiple accounts within the same instance. - {Ownable} is a simpler mechanism with a single owner "role" that can be assigned to a single account. This simpler mechanism can be useful for quick tests but projects with production concerns are likely to outgrow it. @@ -43,3 +44,9 @@ This directory provides ways to restrict who can access the functions of a contr {{AccessManaged}} {{AuthorityUtils}} + +=== Extensions + +{{IAccessManagerEnumerable}} + +{{AccessManagerEnumerable}} diff --git a/contracts/access/manager/extensions/AccessManagerEnumerable.sol b/contracts/access/manager/extensions/AccessManagerEnumerable.sol new file mode 100644 index 00000000000..b8f5d926afb --- /dev/null +++ b/contracts/access/manager/extensions/AccessManagerEnumerable.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IAccessManagerEnumerable} from "./IAccessManagerEnumerable.sol"; +import {AccessManager} from "../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 IAccessManagerEnumerable, AccessManager { + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + + mapping(uint64 roleId => EnumerableSet.AddressSet) private _roleMembers; + mapping(uint64 roleId => mapping(address target => EnumerableSet.Bytes32Set)) private _roleTargetFunctions; + + /// @inheritdoc IAccessManagerEnumerable + function getRoleMember(uint64 roleId, uint256 index) public view virtual returns (address) { + return _roleMembers[roleId].at(index); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleMembers(uint64 roleId, uint256 start, uint256 end) public view virtual returns (address[] memory) { + return _roleMembers[roleId].values(start, end); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleMemberCount(uint64 roleId) public view virtual returns (uint256) { + return _roleMembers[roleId].length(); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleTargetFunction(uint64 roleId, address target, uint256 index) public view virtual returns (bytes4) { + return bytes4(_roleTargetFunctions[roleId][target].at(index)); + } + + /* + * @dev See {IAccessManagerEnumerable-getRoleTargetFunctions} + * + * NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, passing {ADMIN_ROLE} as `roleId` will + * return an empty array. See {_setTargetFunctionRole} for more details. + */ + function getRoleTargetFunctions( + uint64 roleId, + address target, + uint256 start, + uint256 end + ) public view virtual returns (bytes4[] memory) { + bytes32[] memory targetFunctions = _roleTargetFunctions[roleId][target].values(start, end); + bytes4[] memory targetFunctionSelectors; + assembly ("memory-safe") { + targetFunctionSelectors := targetFunctions + } + return targetFunctionSelectors; + } + + /* + * @dev See {IAccessManagerEnumerable-getRoleTargetFunctionCount} + * + * NOTE: Given {ADMIN_ROLE} is the default role for every restricted function, passing {ADMIN_ROLE} as `roleId` will + * return 0. See {_setTargetFunctionRole} for more details. + */ + function getRoleTargetFunctionCount(uint64 roleId, address target) public view virtual returns (uint256) { + return _roleTargetFunctions[roleId][target].length(); + } + + /// @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. + * + * Since the target functions for the {ADMIN_ROLE} can't be tracked exhaustively (i.e. by default, all + * restricted functions), any function that is granted to the {ADMIN_ROLE} will not be tracked by this + * extension. Developers may opt in for tracking the functions for the {ADMIN_ROLE} by overriding, + * though, the tracking would not be exhaustive unless {setTargetFunctionRole} is explicitly called + * for the {ADMIN_ROLE} for each function: + * + * ```solidity + * function _setTargetFunctionRole(address target, bytes4 selector, uint64 roleId) internal virtual override { + * uint64 oldRoleId = getTargetFunctionRole(target, selector); + * super._setTargetFunctionRole(target, selector, roleId); + * if (oldRoleId == ADMIN_ROLE) { + * _roleTargetFunctions[oldRoleId][target].remove(bytes32(selector)); + * } + * if (roleId == ADMIN_ROLE) { + * _roleTargetFunctions[roleId][target].add(bytes32(selector)); + * } + * } + * ``` + */ + function _setTargetFunctionRole(address target, bytes4 selector, uint64 roleId) internal virtual override { + uint64 oldRoleId = getTargetFunctionRole(target, selector); + super._setTargetFunctionRole(target, selector, roleId); + if (oldRoleId != ADMIN_ROLE) { + _roleTargetFunctions[oldRoleId][target].remove(bytes32(selector)); + } + if (roleId != ADMIN_ROLE) { + _roleTargetFunctions[roleId][target].add(bytes32(selector)); + } + } +} diff --git a/contracts/access/manager/extensions/IAccessManagerEnumerable.sol b/contracts/access/manager/extensions/IAccessManagerEnumerable.sol new file mode 100644 index 00000000000..d5561f2c4d7 --- /dev/null +++ b/contracts/access/manager/extensions/IAccessManagerEnumerable.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.4; + +import {IAccessManager} from "../IAccessManager.sol"; + +/** + * @dev External interface of AccessManagerEnumerable. + */ +interface IAccessManagerEnumerable is IAccessManager { + /** + * @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) external view returns (address); + + /** + * @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. + * + * 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) external view returns (address[] memory); + + /** + * @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) external view returns (uint256); + + /** + * @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) external view returns (bytes4); + + /** + * @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. + * + * 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 getRoleTargetFunctions( + uint64 roleId, + address target, + uint256 start, + uint256 end + ) external view returns (bytes4[] memory); + + /** + * @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. + */ + function getRoleTargetFunctionCount(uint64 roleId, address target) external view returns (uint256); +} diff --git a/contracts/mocks/AccessManagerMock.sol b/contracts/mocks/AccessManagerMock.sol index 4b5be350fc6..4d0623142bb 100644 --- a/contracts/mocks/AccessManagerMock.sol +++ b/contracts/mocks/AccessManagerMock.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import {AccessManager} from "../access/manager/AccessManager.sol"; +import {AccessManagerEnumerable} from "../access/manager/extensions/AccessManagerEnumerable.sol"; contract AccessManagerMock is AccessManager { event CalledRestricted(address caller); @@ -18,3 +19,31 @@ contract AccessManagerMock is AccessManager { emit CalledUnrestricted(msg.sender); } } + +contract AccessManagerEnumerableMock is AccessManagerMock, AccessManagerEnumerable { + constructor(address initialAdmin) AccessManagerMock(initialAdmin) {} + + function _grantRole( + uint64 roleId, + address account, + uint32 grantDelay, + uint32 executionDelay + ) internal override(AccessManager, AccessManagerEnumerable) returns (bool) { + return super._grantRole(roleId, account, grantDelay, executionDelay); + } + + function _revokeRole( + uint64 roleId, + address account + ) internal override(AccessManager, AccessManagerEnumerable) returns (bool) { + return super._revokeRole(roleId, account); + } + + function _setTargetFunctionRole( + address target, + bytes4 selector, + uint64 roleId + ) internal override(AccessManager, AccessManagerEnumerable) { + super._setTargetFunctionRole(target, selector, roleId); + } +} diff --git a/docs/modules/ROOT/pages/access-control.adoc b/docs/modules/ROOT/pages/access-control.adoc index d8b8cdb78c1..4ac4e4e480f 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: @@ -272,6 +273,53 @@ 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`]. +=== Querying Privileged Accounts + +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, you can use the xref:api:access.adoc#AccessManagerEnumerable[`AccessManagerEnumerable`] extension. This extension uses `EnumerableSet` internally and provides the following functions for role members: + +* xref:api:access.adoc#AccessManagerEnumerable-getRoleTargetFunctionCount-uint64-address-[`getRoleMemberCount`] +* xref:api:access.adoc#AccessManagerEnumerable-getRoleMember-uint64-uint256-[`getRoleMember`] +* xref:api:access.adoc#AccessManagerEnumerable-getRoleMembers-uint64-uint256-uint256-[`getRoleMembers`] + +And these functions for target function permissions: + +* xref:api:access.adoc#AccessManagerEnumerable-getRoleTargetFunctionCount-uint64-address-[`getRoleTargetFunctionCount`] +* xref:api:access.adoc#AccessManagerEnumerable-getRoleTargetFunction-uint64-address-uint256-[`getRoleTargetFunction`] +* xref:api:access.adoc#AccessManagerEnumerable-getRoleTargetFunctions-uint64-address-uint256-uint256-[`getRoleTargetFunctions`] + +These can be used 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, minterCount); + +// 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, functionCount); +``` + +Note that target function enumeration is organized per target contract, allowing you to query which functions a role can access on each specific target separately. This provides fine-grained visibility into the permission structure across your entire system of managed contracts. + === 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. diff --git a/test/access/manager/AccessManager.behavior.js b/test/access/manager/AccessManager.behavior.js index 830700e3762..f3e68301713 100644 --- a/test/access/manager/AccessManager.behavior.js +++ b/test/access/manager/AccessManager.behavior.js @@ -1,5 +1,6 @@ const { expect } = require('chai'); +const { selector } = require('../../helpers/methods'); const { LIKE_COMMON_IS_EXECUTING, LIKE_COMMON_GET_ACCESS, @@ -248,10 +249,206 @@ function shouldBehaveLikeASelfRestrictedOperation() { }); } +// ============ ENUMERABLE EXTENSION ============ + +/** + * @requires this.{manager,roles,admin,user,other} + */ +function shouldBehaveLikeAccessManagerEnumerable() { + describe('enumerating', function () { + const ANOTHER_ROLE = 0xdeadc0de2n; + + describe('role members', function () { + it('role bearers can be enumerated', async function () { + // Grant roles to multiple accounts + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.user, 0); + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.other, 0); + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.admin, 0); + + // Revoke one role + await this.manager.connect(this.admin).revokeRole(ANOTHER_ROLE, this.other); + + const expectedMembers = [this.user.address, this.admin.address]; + + // Test individual enumeration + const memberCount = await this.manager.getRoleMemberCount(ANOTHER_ROLE); + const members = []; + for (let i = 0; i < memberCount; ++i) { + members.push(await this.manager.getRoleMember(ANOTHER_ROLE, i)); + } + + expect(memberCount).to.equal(expectedMembers.length); + expect(members).to.deep.equal(expectedMembers); + + // Test batch enumeration + await expect(this.manager.getRoleMembers(ANOTHER_ROLE, 0, memberCount)).to.eventually.deep.equal( + expectedMembers, + ); + }); + + it('role enumeration should be in sync after renounceRole call', async function () { + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.user, 0); + + await expect(this.manager.getRoleMemberCount(ANOTHER_ROLE)).to.eventually.equal(1); // Only the initial member + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.admin, 0); + await expect(this.manager.getRoleMemberCount(ANOTHER_ROLE)).to.eventually.equal(2); + await this.manager.connect(this.admin).renounceRole(ANOTHER_ROLE, this.admin); + await expect(this.manager.getRoleMemberCount(ANOTHER_ROLE)).to.eventually.equal(1); + }); + + it('returns empty for roles with no members', async function () { + const roleId = 999n; // Non-existent role + + await expect(this.manager.getRoleMemberCount(roleId)).to.eventually.equal(0); + await expect(this.manager.getRoleMembers(roleId, 0, 10)).to.eventually.deep.equal([]); + }); + + it('supports partial enumeration with start and end parameters', async function () { + // Grant roles to multiple accounts + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.user, 0); + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.other, 0); + await this.manager.connect(this.admin).grantRole(ANOTHER_ROLE, this.admin, 0); + + await expect(this.manager.getRoleMemberCount(ANOTHER_ROLE)).to.eventually.equal(3); + + const users = [this.user.address, this.other.address, this.admin.address]; + + // Test partial enumeration + const firstTwo = await this.manager.getRoleMembers(ANOTHER_ROLE, 0, 2); + expect(firstTwo).to.have.lengthOf(2); + expect(users).to.include.members(firstTwo); + + const lastTwo = await this.manager.getRoleMembers(ANOTHER_ROLE, 1, 3); + expect(lastTwo).to.have.lengthOf(2); + expect(users).to.include.members(lastTwo); + }); + }); + + describe('target functions', function () { + it('target functions can be enumerated', async function () { + const roleId = this.roles.SOME.id; + const target = this.target; + const selectors = [ + selector('someFunction()'), + selector('anotherFunction(uint256)'), + selector('thirdFunction(address,bool)'), + ]; + + await this.manager.connect(this.admin).setTargetFunctionRole(target, selectors, roleId); + + const functionCount = await this.manager.getRoleTargetFunctionCount(roleId, target); + expect(functionCount).to.equal(selectors.length); + + // Test individual enumeration + const functions = []; + for (let i = 0; i < functionCount; ++i) { + functions.push(this.manager.getRoleTargetFunction(roleId, target, i)); + } + await expect(Promise.all(functions)).to.eventually.have.members(selectors); + + // Test batch enumeration + const batchFunctions = await this.manager.getRoleTargetFunctions(roleId, target, 0, functionCount); + expect([...batchFunctions]).to.have.members(selectors); + }); + + it('target function enumeration updates when roles change', async function () { + const roleId1 = this.roles.SOME.id; + const roleId2 = this.roles.SOME_ADMIN.id; + const target = this.target; + const sel = selector('testFunction()'); + + // Initially assign to roleId1 + await this.manager.connect(this.admin).setTargetFunctionRole(target, [sel], roleId1); + + await expect(this.manager.getRoleTargetFunctionCount(roleId1, target)).to.eventually.equal(1); + await expect(this.manager.getRoleTargetFunctionCount(roleId2, target)).to.eventually.equal(0); + await expect(this.manager.getRoleTargetFunction(roleId1, target, 0)).to.eventually.equal(sel); + + // Reassign to roleId2 + await this.manager.connect(this.admin).setTargetFunctionRole(target, [sel], roleId2); + + await expect(this.manager.getRoleTargetFunctionCount(roleId1, target)).to.eventually.equal(0); + await expect(this.manager.getRoleTargetFunctionCount(roleId2, target)).to.eventually.equal(1); + await expect(this.manager.getRoleTargetFunction(roleId2, target, 0)).to.eventually.equal(sel); + }); + + it('returns empty for ADMIN_ROLE target functions', async function () { + const target = this.target; + const sel = selector('adminFunction()'); + + // Set function to ADMIN_ROLE (default behavior) + await this.manager.connect(this.admin).setTargetFunctionRole(target, [sel], this.roles.ADMIN.id); + + // ADMIN_ROLE functions are not tracked + await expect(this.manager.getRoleTargetFunctionCount(this.roles.ADMIN.id, target)).to.eventually.equal(0); + await expect(this.manager.getRoleTargetFunctions(this.roles.ADMIN.id, target, 0, 10)).to.eventually.deep.equal( + [], + ); + }); + + it('returns empty for roles with no target functions', async function () { + const roleId = 888n; // Role with no functions + const target = this.target; + + await expect(this.manager.getRoleTargetFunctionCount(roleId, target)).to.eventually.equal(0); + await expect(this.manager.getRoleTargetFunctions(roleId, target, 0, 10)).to.eventually.deep.equal([]); + }); + + it('supports partial enumeration of target functions', async function () { + const roleId = this.roles.SOME.id; + const target = this.target; + const selectors = [selector('func1()'), selector('func2()'), selector('func3()'), selector('func4()')]; + + await this.manager.connect(this.admin).setTargetFunctionRole(target, selectors, roleId); + + await expect(this.manager.getRoleTargetFunctionCount(roleId, target)).to.eventually.equal(4); + + // Test partial enumeration + const firstTwo = await this.manager.getRoleTargetFunctions(roleId, target, 0, 2); + expect(firstTwo).to.have.lengthOf(2); + + const lastTwo = await this.manager.getRoleTargetFunctions(roleId, target, 2, 4); + expect(lastTwo).to.have.lengthOf(2); + + // Verify no overlap and complete coverage + const allFunctions = [...firstTwo, ...lastTwo]; + expect(allFunctions).to.have.members(selectors); + }); + + it('distinguishes between different targets', async function () { + const roleId = this.roles.SOME.id; + const target1 = this.target; + const target2 = this.target2; + const sel1 = selector('target1Function()'); + const sel2 = selector('target2Function()'); + + // Set different functions for the same role on different targets + await this.manager.connect(this.admin).setTargetFunctionRole(target1, [sel1], roleId); + await this.manager.connect(this.admin).setTargetFunctionRole(target2, [sel2], roleId); + + // Each target should have its own function tracked + await expect(this.manager.getRoleTargetFunctionCount(roleId, target1)).to.eventually.equal(1); + await expect(this.manager.getRoleTargetFunctionCount(roleId, target2)).to.eventually.equal(1); + + await expect(this.manager.getRoleTargetFunction(roleId, target1, 0)).to.eventually.equal(sel1); + await expect(this.manager.getRoleTargetFunction(roleId, target2, 0)).to.eventually.equal(sel2); + + // Functions should be isolated per target + const target1Functions = await this.manager.getRoleTargetFunctions(roleId, target1, 0, 1); + const target2Functions = await this.manager.getRoleTargetFunctions(roleId, target2, 0, 1); + + expect(target1Functions).to.deep.equal([sel1]); + expect(target2Functions).to.deep.equal([sel2]); + }); + }); + }); +} + module.exports = { shouldBehaveLikeDelayedAdminOperation, shouldBehaveLikeNotDelayedAdminOperation, shouldBehaveLikeRoleAdminOperation, shouldBehaveLikeAManagedRestrictedOperation, shouldBehaveLikeASelfRestrictedOperation, + shouldBehaveLikeAccessManagerEnumerable, }; diff --git a/test/access/manager/extensions/AccessManagerEnumerable.test.js b/test/access/manager/extensions/AccessManagerEnumerable.test.js new file mode 100644 index 00000000000..d0f3250331d --- /dev/null +++ b/test/access/manager/extensions/AccessManagerEnumerable.test.js @@ -0,0 +1,64 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { buildBaseRoles } = require('../../../helpers/access-manager'); +const { shouldBehaveLikeAccessManagerEnumerable } = require('../AccessManager.behavior'); + +async function fixture() { + const [admin, roleAdmin, roleGuardian, member, user, other] = await ethers.getSigners(); + + // Build roles + const roles = buildBaseRoles(); + + // Add members + roles.ADMIN.members = [admin]; + roles.SOME_ADMIN.members = [roleAdmin]; + roles.SOME_GUARDIAN.members = [roleGuardian]; + roles.SOME.members = [member]; + roles.PUBLIC.members = [admin, roleAdmin, roleGuardian, member, user, other]; + + const manager = await ethers.deployContract('$AccessManagerEnumerableMock', [admin]); + const target = await ethers.deployContract('$AccessManagedTarget', [manager]); + const target2 = await ethers.deployContract('$AccessManagedTarget', [manager]); + + for (const { id: roleId, admin, guardian, members } of Object.values(roles)) { + if (roleId === roles.PUBLIC.id) continue; // Every address belong to public and is locked + if (roleId === roles.ADMIN.id) continue; // Admin set during construction and is locked + + // Set admin role avoiding default + if (admin.id !== roles.ADMIN.id) { + await manager.$_setRoleAdmin(roleId, admin.id); + } + + // Set guardian role avoiding default + if (guardian.id !== roles.ADMIN.id) { + await manager.$_setRoleGuardian(roleId, guardian.id); + } + + // Grant role to members + for (const member of members) { + await manager.$_grantRole(roleId, member, 0, 0); + } + } + + return { + admin, + roleAdmin, + roleGuardian, + member, + user, + other, + roles, + manager, + target, + target2, + }; +} + +describe('AccessManagerEnumerable', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccessManagerEnumerable(); +});