diff --git a/.changeset/quiet-trainers-kick.md b/.changeset/quiet-trainers-kick.md new file mode 100644 index 00000000000..5de96467de8 --- /dev/null +++ b/.changeset/quiet-trainers-kick.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`AccessManager`: Added a new contract for managing access control of complex systems in a consolidated location. diff --git a/contracts/access/README.adoc b/contracts/access/README.adoc index 80ca0020f4b..59d5d86a312 100644 --- a/contracts/access/README.adoc +++ b/contracts/access/README.adoc @@ -25,3 +25,13 @@ This directory provides ways to restrict who can access the functions of a contr {{AccessControlEnumerable}} {{AccessControlDefaultAdminRules}} + +== AccessManager + +{{IAuthority}} + +{{AccessManager}} + +{{AccessManaged}} + +{{AccessManagerAdapter}} diff --git a/contracts/access/manager/AccessManaged.sol b/contracts/access/manager/AccessManaged.sol new file mode 100644 index 00000000000..2dcdeb7aae6 --- /dev/null +++ b/contracts/access/manager/AccessManaged.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../../utils/Context.sol"; +import "./IAuthority.sol"; + +/** + * @dev This contract module makes available a {restricted} modifier. Functions decorated with this modifier will be + * permissioned according to an "authority": a contract like {AccessManager} that follows the {IAuthority} interface, + * implementing a policy that allows certain callers access to certain functions. + * + * IMPORTANT: The `restricted` modifier should never be used on `internal` functions, judiciously used in `public` + * functions, and ideally only used in `external` functions. See {restricted}. + */ +contract AccessManaged is Context { + event AuthorityUpdated(address indexed sender, IAuthority indexed newAuthority); + + IAuthority private _authority; + + /** + * @dev Restricts access to a function as defined by the connected Authority for this contract and the + * caller and selector of the function that entered the contract. + * + * [IMPORTANT] + * ==== + * In general, this modifier should only be used on `external` functions. It is okay to use it on `public` functions + * that are used as external entry points and are not called internally. Unless you know what you're doing, it + * should never be used on `internal` functions. Failure to follow these rules can have critical security + * implications! This is because the permissions are determined by the function that entered the contract, i.e. the + * function at the bottom of the call stack, and not the function where the modifier is visible in the source code. + * ==== + */ + modifier restricted() { + _checkCanCall(_msgSender(), msg.sig); + _; + } + + /** + * @dev Initializes the contract connected to an initial authority. + */ + constructor(IAuthority initialAuthority) { + _setAuthority(initialAuthority); + } + + /** + * @dev Returns the current authority. + */ + function authority() public view virtual returns (IAuthority) { + return _authority; + } + + /** + * @dev Transfers control to a new authority. The caller must be the current authority. + */ + function setAuthority(IAuthority newAuthority) public virtual { + require(_msgSender() == address(_authority), "AccessManaged: not current authority"); + _setAuthority(newAuthority); + } + + /** + * @dev Transfers control to a new authority. Internal function with no access restriction. + */ + function _setAuthority(IAuthority newAuthority) internal virtual { + _authority = newAuthority; + emit AuthorityUpdated(_msgSender(), newAuthority); + } + + /** + * @dev Reverts if the caller is not allowed to call the function identified by a selector. + */ + function _checkCanCall(address caller, bytes4 selector) internal view virtual { + require(_authority.canCall(caller, address(this), selector), "AccessManaged: authority rejected"); + } +} diff --git a/contracts/access/manager/AccessManager.sol b/contracts/access/manager/AccessManager.sol new file mode 100644 index 00000000000..8820ef4a06c --- /dev/null +++ b/contracts/access/manager/AccessManager.sol @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.13; + +import "../AccessControl.sol"; +import "../AccessControlDefaultAdminRules.sol"; +import "./IAuthority.sol"; +import "./AccessManaged.sol"; + +interface IAccessManager is IAuthority, IAccessControlDefaultAdminRules { + enum AccessMode { + Custom, + Closed, + Open + } + + event GroupUpdated(uint8 indexed group, string name); + + event GroupAllowed(address indexed target, bytes4 indexed selector, uint8 indexed group, bool allowed); + + event AccessModeUpdated(address indexed target, AccessMode indexed mode); + + function createGroup(uint8 group, string calldata name) external; + + function updateGroupName(uint8 group, string calldata name) external; + + function hasGroup(uint8 group) external view returns (bool); + + function getUserGroups(address user) external view returns (bytes32 groups); + + function grantGroup(uint8 group, address user) external; + + function revokeGroup(uint8 group, address user) external; + + function renounceGroup(uint8 group, address user) external; + + function getFunctionAllowedGroups(address target, bytes4 selector) external view returns (bytes32 groups); + + function setFunctionAllowedGroup(address target, bytes4[] calldata selectors, uint8 group, bool allowed) external; + + function getContractMode(address target) external view returns (AccessMode); + + function setContractModeCustom(address target) external; + + function setContractModeOpen(address target) external; + + function setContractModeClosed(address target) external; + + function transferContractAuthority(address target, address newAuthority) external; +} + +/** + * @dev AccessManager is a central contract to store the permissions of a system. + * + * The smart contracts under the control of an AccessManager instance will have a set of "restricted" functions, and the + * exact details of how access is restricted for each of those functions is configurable by the admins of the instance. + * These restrictions are expressed in terms of "groups". + * + * An AccessManager instance will define a set of groups. Each of them must be created before they can be granted, with + * a maximum of 255 created groups. Users can be added into any number of these groups. Each of them defines an + * AccessControl role, and may confer access to some of the restricted functions in the system, as configured by admins + * through the use of {setFunctionAllowedGroup}. + * + * Note that a function in a target contract may become permissioned in this way only when: 1) said contract is + * {AccessManaged} and is connected to this contract as its manager, and 2) said function is decorated with the + * `restricted` modifier. + * + * There is a special group defined by default named "public" which all accounts automatically have. + * + * Contracts can also be configured in two special modes: 1) the "open" mode, where all functions are allowed to the + * "public" group, and 2) the "closed" mode, where no function is allowed to any group. + * + * Since all the permissions of the managed system can be modified by the admins of this instance, it is expected that + * it will be highly secured (e.g., a multisig or a well-configured DAO). Additionally, {AccessControlDefaultAdminRules} + * is included to enforce security rules on this account. + * + * NOTE: Some of the functions in this contract, such as {getUserGroups}, return a `bytes32` bitmap to succintly + * represent a set of groups. In a bitmap, bit `n` (counting from the least significant bit) will be 1 if and only if + * the group with number `n` is in the set. For example, the hex value `0x05` represents the set of the two groups + * numbered 0 and 2 from its binary equivalence `0b101` + */ +contract AccessManager is IAccessManager, AccessControlDefaultAdminRules { + bytes32 _createdGroups; + + // user -> groups + mapping(address => bytes32) private _userGroups; + + // target -> selector -> groups + mapping(address => mapping(bytes4 => bytes32)) private _allowedGroups; + + // target -> mode + mapping(address => AccessMode) private _contractMode; + + uint8 private constant _GROUP_PUBLIC = type(uint8).max; + + /** + * @dev Initializes an AccessManager with initial default admin and transfer delay. + */ + constructor( + uint48 initialDefaultAdminDelay, + address initialDefaultAdmin + ) AccessControlDefaultAdminRules(initialDefaultAdminDelay, initialDefaultAdmin) { + _createGroup(_GROUP_PUBLIC, "public"); + } + + /** + * @dev Returns true if the caller can invoke on a target the function identified by a function selector. + * Entrypoint for {AccessManaged} contracts. + */ + function canCall(address caller, address target, bytes4 selector) public view virtual returns (bool) { + bytes32 allowedGroups = getFunctionAllowedGroups(target, selector); + bytes32 callerGroups = getUserGroups(caller); + return callerGroups & allowedGroups != 0; + } + + /** + * @dev Creates a new group with a group number that can be chosen arbitrarily but must be unused, and gives it a + * human-readable name. The caller must be the default admin. + * + * Group numbers are not auto-incremented in order to avoid race conditions, but administrators can safely use + * sequential numbers. + * + * Emits {GroupUpdated}. + */ + function createGroup(uint8 group, string memory name) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _createGroup(group, name); + } + + /** + * @dev Updates an existing group's name. The caller must be the default admin. + */ + function updateGroupName(uint8 group, string memory name) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + require(group != _GROUP_PUBLIC, "AccessManager: built-in group"); + require(hasGroup(group), "AccessManager: unknown group"); + emit GroupUpdated(group, name); + } + + /** + * @dev Returns true if the group has already been created via {createGroup}. + */ + function hasGroup(uint8 group) public view virtual returns (bool) { + return _getGroup(_createdGroups, group); + } + + /** + * @dev Returns a bitmap of the groups the user has. See note on bitmaps above. + */ + function getUserGroups(address user) public view virtual returns (bytes32) { + return _userGroups[user] | _groupMask(_GROUP_PUBLIC); + } + + /** + * @dev Grants a user a group. + * + * Emits {RoleGranted} with the role id of the group, if wasn't already held by the user. + */ + function grantGroup(uint8 group, address user) public virtual { + grantRole(_encodeGroupRole(group), user); // will check msg.sender + } + + /** + * @dev Removes a group from a user. + * + * Emits {RoleRevoked} with the role id of the group, if previously held by the user. + */ + function revokeGroup(uint8 group, address user) public virtual { + revokeRole(_encodeGroupRole(group), user); // will check msg.sender + } + + /** + * @dev Allows a user to renounce a group. + * + * Emits {RoleRevoked} with the role id of the group, if previously held by the user. + */ + function renounceGroup(uint8 group, address user) public virtual { + renounceRole(_encodeGroupRole(group), user); // will check msg.sender + } + + /** + * @dev Returns a bitmap of the groups that are allowed to call a function of a target contract. If the target + * contract is in open or closed mode it will be reflected in the return value. + */ + function getFunctionAllowedGroups(address target, bytes4 selector) public view virtual returns (bytes32) { + AccessMode mode = getContractMode(target); + if (mode == AccessMode.Open) { + return _groupMask(_GROUP_PUBLIC); + } else if (mode == AccessMode.Closed) { + return 0; + } else { + return _allowedGroups[target][selector]; + } + } + + /** + * @dev Changes whether a group is allowed to call a function of a contract, according to the `allowed` argument. + * The caller must be the default admin. + */ + function setFunctionAllowedGroup( + address target, + bytes4[] calldata selectors, + uint8 group, + bool allowed + ) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + for (uint256 i = 0; i < selectors.length; i++) { + bytes4 selector = selectors[i]; + _allowedGroups[target][selector] = _withUpdatedGroup(_allowedGroups[target][selector], group, allowed); + emit GroupAllowed(target, selector, group, allowed); + } + } + + /** + * @dev Returns the mode of the target contract, which may be custom (`0`), closed (`1`), or open (`2`). + */ + function getContractMode(address target) public view virtual returns (AccessMode) { + return _contractMode[target]; + } + + /** + * @dev Sets the target contract to be in custom restricted mode. All restricted functions in the target contract + * will follow the group-based restrictions defined by the AccessManager. The caller must be the default admin. + */ + function setContractModeCustom(address target) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _setContractMode(target, AccessMode.Custom); + } + + /** + * @dev Sets the target contract to be in "open" mode. All restricted functions in the target contract will become + * callable by anyone. The caller must be the default admin. + */ + function setContractModeOpen(address target) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _setContractMode(target, AccessMode.Open); + } + + /** + * @dev Sets the target contract to be in "closed" mode. All restricted functions in the target contract will be + * closed down and disallowed to all. The caller must be the default admin. + */ + function setContractModeClosed(address target) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _setContractMode(target, AccessMode.Closed); + } + + /** + * @dev Transfers a target contract onto a new authority. The caller must be the default admin. + */ + function transferContractAuthority( + address target, + address newAuthority + ) public virtual onlyRole(DEFAULT_ADMIN_ROLE) { + AccessManaged(target).setAuthority(IAuthority(newAuthority)); + } + + /** + * @dev Creates a new group. + * + * Emits {GroupUpdated}. + */ + function _createGroup(uint8 group, string memory name) internal virtual { + require(!hasGroup(group), "AccessManager: existing group"); + _createdGroups = _withUpdatedGroup(_createdGroups, group, true); + emit GroupUpdated(group, name); + } + + /** + * @dev Augmented version of {AccessControl-_grantRole} that keeps track of user group bitmaps. + */ + function _grantRole(bytes32 role, address user) internal virtual override { + super._grantRole(role, user); + (bool isGroup, uint8 group) = _decodeGroupRole(role); + if (isGroup) { + require(hasGroup(group), "AccessManager: unknown group"); + _userGroups[user] = _withUpdatedGroup(_userGroups[user], group, true); + } + } + + /** + * @dev Augmented version of {AccessControl-_revokeRole} that keeps track of user group bitmaps. + */ + function _revokeRole(bytes32 role, address user) internal virtual override { + super._revokeRole(role, user); + (bool isGroup, uint8 group) = _decodeGroupRole(role); + if (isGroup) { + require(hasGroup(group), "AccessManager: unknown group"); + require(group != _GROUP_PUBLIC, "AccessManager: irrevocable group"); + _userGroups[user] = _withUpdatedGroup(_userGroups[user], group, false); + } + } + + /** + * @dev Sets the restricted mode of a target contract. + */ + function _setContractMode(address target, AccessMode mode) internal virtual { + _contractMode[target] = mode; + emit AccessModeUpdated(target, mode); + } + + /** + * @dev Returns the {AccessControl} role id that corresponds to a group. + * + * This role id starts with the ASCII characters `group:`, followed by zeroes, and ends with the single byte + * corresponding to the group number. + */ + function _encodeGroupRole(uint8 group) internal pure virtual returns (bytes32) { + return bytes32("group:") | bytes32(uint256(group)); + } + + /** + * @dev Decodes a role id into a group, if it is a role id of the kind returned by {_encodeGroupRole}. + */ + function _decodeGroupRole(bytes32 role) internal pure virtual returns (bool isGroup, uint8 group) { + bytes32 tagMask = ~bytes32(uint256(0xff)); + bytes32 tag = role & tagMask; + isGroup = tag == bytes32("group:"); + group = uint8(role[31]); + } + + /** + * @dev Returns a bit mask where the only non-zero bit is the group number bit. + */ + function _groupMask(uint8 group) private pure returns (bytes32) { + return bytes32(1 << group); + } + + /** + * @dev Returns the value of the group number bit in a bitmap. + */ + function _getGroup(bytes32 bitmap, uint8 group) private pure returns (bool) { + return bitmap & _groupMask(group) > 0; + } + + /** + * @dev Returns a new group bitmap where a specific group was updated. + */ + function _withUpdatedGroup(bytes32 bitmap, uint8 group, bool value) private pure returns (bytes32) { + bytes32 mask = _groupMask(group); + if (value) { + return bitmap | mask; + } else { + return bitmap & ~mask; + } + } +} diff --git a/contracts/access/manager/AccessManagerAdapter.sol b/contracts/access/manager/AccessManagerAdapter.sol new file mode 100644 index 00000000000..afa92264a89 --- /dev/null +++ b/contracts/access/manager/AccessManagerAdapter.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./AccessManager.sol"; +import "./AccessManaged.sol"; + +/** + * @dev This contract can be used to migrate existing {Ownable} or {AccessControl} contracts into an {AccessManager} + * system. + * + * Ownable contracts can have their ownership transferred to an instance of this adapter. AccessControl contracts can + * grant all roles to the adapter, while ideally revoking them from all other accounts. Subsequently, the permissions + * for those contracts can be managed centrally and with function granularity in the {AccessManager} instance the + * adapter is connected to. + * + * Permissioned interactions with thus migrated contracts must go through the adapter's {relay} function and will + * proceed if the function is allowed for the caller in the AccessManager instance. + */ +contract AccessManagerAdapter is AccessManaged { + bytes32 private constant _DEFAULT_ADMIN_ROLE = 0; + + /** + * @dev Initializes an adapter connected to an AccessManager instance. + */ + constructor(AccessManager manager) AccessManaged(manager) {} + + /** + * @dev Relays a function call to the target contract. The call will be relayed if the AccessManager allows the + * caller access to this function in the target contract, i.e. if the caller is in a team that is allowed for the + * function, or if the caller is the default admin for the AccessManager. The latter is meant to be used for + * ad hoc operations such as asset recovery. + */ + function relay(address target, bytes memory data) external payable { + bytes4 sig = bytes4(data); + AccessManager manager = AccessManager(address(authority())); + require( + manager.canCall(msg.sender, target, sig) || manager.hasRole(_DEFAULT_ADMIN_ROLE, msg.sender), + "AccessManagerAdapter: caller not allowed" + ); + (bool ok, bytes memory result) = target.call{value: msg.value}(data); + assembly { + let result_pointer := add(32, result) + let result_size := mload(result) + switch ok + case true { + return(result_pointer, result_size) + } + default { + revert(result_pointer, result_size) + } + } + } +} diff --git a/contracts/access/manager/IAuthority.sol b/contracts/access/manager/IAuthority.sol new file mode 100644 index 00000000000..61a85d2f98e --- /dev/null +++ b/contracts/access/manager/IAuthority.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev Standard interface for permissioning originally defined in Dappsys. + */ +interface IAuthority { + /** + * @dev Returns true if the caller can invoke on a target the function identified by a function selector. + */ + function canCall(address caller, address target, bytes4 selector) external view returns (bool allowed); +} diff --git a/contracts/mocks/AccessManagerMocks.sol b/contracts/mocks/AccessManagerMocks.sol new file mode 100644 index 00000000000..caf81366e63 --- /dev/null +++ b/contracts/mocks/AccessManagerMocks.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.13; + +import "../access/manager/IAuthority.sol"; +import "../access/manager/AccessManaged.sol"; + +contract SimpleAuthority is IAuthority { + address _allowedCaller; + address _allowedTarget; + bytes4 _allowedSelector; + + function setAllowed(address allowedCaller, address allowedTarget, bytes4 allowedSelector) public { + _allowedCaller = allowedCaller; + _allowedTarget = allowedTarget; + _allowedSelector = allowedSelector; + } + + function canCall(address caller, address target, bytes4 selector) external view override returns (bool) { + return caller == _allowedCaller && target == _allowedTarget && selector == _allowedSelector; + } +} + +abstract contract AccessManagedMock is AccessManaged { + event RestrictedRan(); + + function restrictedFunction() external restricted { + emit RestrictedRan(); + } + + function otherRestrictedFunction() external restricted { + emit RestrictedRan(); + } +} diff --git a/hardhat.config.js b/hardhat.config.js index 32f721b656c..8edddd2f6e7 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -3,7 +3,7 @@ // - COVERAGE: enable coverage report // - ENABLE_GAS_REPORT: enable gas report // - COMPILE_MODE: production modes enables optimizations (default: development) -// - COMPILE_VERSION: compiler version (default: 0.8.9) +// - COMPILE_VERSION: compiler version // - COINMARKETCAP: coinmarkercat api key for USD value in gas report const fs = require('fs'); diff --git a/test/access/manager/AccessManaged.test.js b/test/access/manager/AccessManaged.test.js new file mode 100644 index 00000000000..caf1eea7e5c --- /dev/null +++ b/test/access/manager/AccessManaged.test.js @@ -0,0 +1,55 @@ +const { + expectEvent, + expectRevert, + constants: { ZERO_ADDRESS }, +} = require('@openzeppelin/test-helpers'); + +const AccessManaged = artifacts.require('$AccessManagedMock'); +const SimpleAuthority = artifacts.require('SimpleAuthority'); + +contract('AccessManaged', function (accounts) { + const [authority, other, user] = accounts; + it('construction', async function () { + const managed = await AccessManaged.new(authority); + expectEvent.inConstruction(managed, 'AuthorityUpdated', { + oldAuthority: ZERO_ADDRESS, + newAuthority: authority, + }); + expect(await managed.authority()).to.equal(authority); + }); + + describe('setAuthority', function () { + it(`current authority can change managed's authority`, async function () { + const managed = await AccessManaged.new(authority); + const set = await managed.setAuthority(other, { from: authority }); + expectEvent(set, 'AuthorityUpdated', { + sender: authority, + newAuthority: other, + }); + expect(await managed.authority()).to.equal(other); + }); + + it(`other account cannot change managed's authority`, async function () { + const managed = await AccessManaged.new(authority); + await expectRevert(managed.setAuthority(other, { from: other }), 'AccessManaged: not current authority'); + }); + }); + + describe('restricted', function () { + const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()'); + + it('allows if authority returns true', async function () { + const authority = await SimpleAuthority.new(); + const managed = await AccessManaged.new(authority.address); + await authority.setAllowed(user, managed.address, selector); + const restricted = await managed.restrictedFunction({ from: user }); + expectEvent(restricted, 'RestrictedRan'); + }); + + it('reverts if authority returns false', async function () { + const authority = await SimpleAuthority.new(); + const managed = await AccessManaged.new(authority.address); + await expectRevert(managed.restrictedFunction({ from: user }), 'AccessManaged: authority rejected'); + }); + }); +}); diff --git a/test/access/manager/AccessManager.test.js b/test/access/manager/AccessManager.test.js new file mode 100644 index 00000000000..bf194d3885a --- /dev/null +++ b/test/access/manager/AccessManager.test.js @@ -0,0 +1,506 @@ +const { + expectEvent, + expectRevert, + time: { duration }, +} = require('@openzeppelin/test-helpers'); +const { AccessMode } = require('../../helpers/enums'); + +const AccessManager = artifacts.require('AccessManager'); +const AccessManagerAdapter = artifacts.require('AccessManagerAdapter'); +const AccessManaged = artifacts.require('$AccessManagedMock'); + +const Ownable = artifacts.require('$Ownable'); +const AccessControl = artifacts.require('$AccessControl'); + +const groupUtils = { + mask: group => 1n << BigInt(group), + decodeBitmap: hexBitmap => { + const m = BigInt(hexBitmap); + const allGroups = new Array(256).fill().map((_, i) => i.toString()); + return allGroups.filter(i => (m & groupUtils.mask(i)) !== 0n); + }, + role: group => web3.utils.asciiToHex('group:').padEnd(64, '0') + group.toString(16).padStart(2, '0'), +}; + +const PUBLIC_GROUP = '255'; + +contract('AccessManager', function (accounts) { + const [admin, nonAdmin, user1, user2, otherAuthority] = accounts; + beforeEach('deploy', async function () { + this.delay = duration.days(1); + this.manager = await AccessManager.new(this.delay, admin); + }); + + it('configures default admin rules', async function () { + expect(await this.manager.defaultAdmin()).to.equal(admin); + expect(await this.manager.defaultAdminDelay()).to.be.bignumber.equal(this.delay); + }); + + describe('groups', function () { + const group = '0'; + const name = 'dao'; + const otherGroup = '1'; + const otherName = 'council'; + + describe('public group', function () { + it('is created automatically', async function () { + await expectEvent.inConstruction(this.manager, 'GroupUpdated', { + group: PUBLIC_GROUP, + name: 'public', + }); + }); + + it('includes all users automatically', async function () { + const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1)); + expect(groups).to.include(PUBLIC_GROUP); + }); + }); + + describe('creating', function () { + it('admin can create groups', async function () { + const created = await this.manager.createGroup(group, name, { from: admin }); + expectEvent(created, 'GroupUpdated', { group, name }); + expect(await this.manager.hasGroup(group)).to.equal(true); + expect(await this.manager.hasGroup(otherGroup)).to.equal(false); + }); + + it('non-admin cannot create groups', async function () { + await expectRevert(this.manager.createGroup(group, name, { from: nonAdmin }), 'missing role'); + }); + + it('cannot recreate a group', async function () { + await this.manager.createGroup(group, name, { from: admin }); + await expectRevert(this.manager.createGroup(group, name, { from: admin }), 'AccessManager: existing group'); + }); + }); + + describe('updating', function () { + beforeEach('create group', async function () { + await this.manager.createGroup(group, name, { from: admin }); + }); + + it('admin can update group', async function () { + const updated = await this.manager.updateGroupName(group, otherName, { from: admin }); + expectEvent(updated, 'GroupUpdated', { group, name: otherName }); + }); + + it('non-admin cannot update group', async function () { + await expectRevert(this.manager.updateGroupName(group, name, { from: nonAdmin }), 'missing role'); + }); + + it('cannot update built in group', async function () { + await expectRevert( + this.manager.updateGroupName(PUBLIC_GROUP, name, { from: admin }), + 'AccessManager: built-in group', + ); + }); + + it('cannot update nonexistent group', async function () { + await expectRevert( + this.manager.updateGroupName(otherGroup, name, { from: admin }), + 'AccessManager: unknown group', + ); + }); + }); + + describe('granting', function () { + beforeEach('create group', async function () { + await this.manager.createGroup(group, name, { from: admin }); + }); + + it('admin can grant group', async function () { + const granted = await this.manager.grantGroup(group, user1, { from: admin }); + expectEvent(granted, 'RoleGranted', { account: user1, role: groupUtils.role(group) }); + const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1)); + expect(groups).to.include(group); + }); + + it('non-admin cannot grant group', async function () { + await expectRevert(this.manager.grantGroup(group, user1, { from: nonAdmin }), 'missing role'); + }); + + it('cannot grant nonexistent group', async function () { + await expectRevert(this.manager.grantGroup(otherGroup, user1, { from: admin }), 'AccessManager: unknown group'); + }); + }); + + describe('revoking & renouncing', function () { + beforeEach('create and grant group', async function () { + await this.manager.createGroup(group, name, { from: admin }); + await this.manager.grantGroup(group, user1, { from: admin }); + }); + + it('admin can revoke group', async function () { + await this.manager.revokeGroup(group, user1, { from: admin }); + const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1)); + expect(groups).to.not.include(group); + }); + + it('non-admin cannot revoke group', async function () { + await expectRevert(this.manager.revokeGroup(group, user1, { from: nonAdmin }), 'missing role'); + }); + + it('user can renounce group', async function () { + await this.manager.renounceGroup(group, user1, { from: user1 }); + const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1)); + expect(groups).to.not.include(group); + }); + + it(`user cannot renounce other user's groups`, async function () { + await expectRevert( + this.manager.renounceGroup(group, user1, { from: user2 }), + 'can only renounce roles for self', + ); + await expectRevert( + this.manager.renounceGroup(group, user2, { from: user1 }), + 'can only renounce roles for self', + ); + }); + + it('cannot revoke public group', async function () { + await expectRevert( + this.manager.revokeGroup(PUBLIC_GROUP, user1, { from: admin }), + 'AccessManager: irrevocable group', + ); + }); + + it('cannot revoke nonexistent group', async function () { + await expectRevert( + this.manager.revokeGroup(otherGroup, user1, { from: admin }), + 'AccessManager: unknown group', + ); + await expectRevert( + this.manager.renounceGroup(otherGroup, user1, { from: user1 }), + 'AccessManager: unknown group', + ); + }); + }); + + describe('querying', function () { + it('returns expected groups', async function () { + const getGroups = () => this.manager.getUserGroups(user1); + + // only public group initially + expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000000000'); + + await this.manager.createGroup('0', '0', { from: admin }); + await this.manager.grantGroup('0', user1, { from: admin }); + expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000000001'); + + await this.manager.createGroup('1', '1', { from: admin }); + await this.manager.grantGroup('1', user1, { from: admin }); + expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000000003'); + + await this.manager.createGroup('16', '16', { from: admin }); + await this.manager.grantGroup('16', user1, { from: admin }); + expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000010003'); + }); + }); + }); + + describe('allowing', function () { + const group = '1'; + const groupMember = user1; + const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()'); + const otherSelector = web3.eth.abi.encodeFunctionSignature('otherRestrictedFunction()'); + + beforeEach('deploying managed contract', async function () { + await this.manager.createGroup(group, '', { from: admin }); + await this.manager.grantGroup(group, groupMember, { from: admin }); + this.managed = await AccessManaged.new(this.manager.address); + }); + + it('non-admin cannot change allowed groups', async function () { + await expectRevert( + this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, true, { from: nonAdmin }), + 'missing role', + ); + }); + + it('single selector', async function () { + const receipt = await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, true, { + from: admin, + }); + + expectEvent(receipt, 'GroupAllowed', { + target: this.managed.address, + selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4 + group, + allowed: true, + }); + + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]); + + const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector); + expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([]); + + const restricted = await this.managed.restrictedFunction({ from: groupMember }); + expectEvent(restricted, 'RestrictedRan'); + + await expectRevert( + this.managed.otherRestrictedFunction({ from: groupMember }), + 'AccessManaged: authority rejected', + ); + }); + + it('multiple selectors', async function () { + const receipt = await this.manager.setFunctionAllowedGroup( + this.managed.address, + [selector, otherSelector], + group, + true, + { from: admin }, + ); + + expectEvent(receipt, 'GroupAllowed', { + target: this.managed.address, + selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4 + group, + allowed: true, + }); + + expectEvent(receipt, 'GroupAllowed', { + target: this.managed.address, + selector: otherSelector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4 + group, + allowed: true, + }); + + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]); + + const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector); + expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([group]); + + const restricted = await this.managed.restrictedFunction({ from: groupMember }); + expectEvent(restricted, 'RestrictedRan'); + + await this.managed.otherRestrictedFunction({ from: groupMember }); + expectEvent(restricted, 'RestrictedRan'); + }); + + it('works on open target', async function () { + await this.manager.setContractModeOpen(this.managed.address, { from: admin }); + await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin }); + }); + + it('works on closed target', async function () { + await this.manager.setContractModeClosed(this.managed.address, { from: admin }); + await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin }); + }); + }); + + describe('disallowing', function () { + const group = '1'; + const groupMember = user1; + const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()'); + const otherSelector = web3.eth.abi.encodeFunctionSignature('otherRestrictedFunction()'); + + beforeEach('deploying managed contract', async function () { + await this.manager.createGroup(group, '', { from: admin }); + await this.manager.grantGroup(group, groupMember, { from: admin }); + this.managed = await AccessManaged.new(this.manager.address); + await this.manager.setFunctionAllowedGroup(this.managed.address, [selector, otherSelector], group, true, { + from: admin, + }); + }); + + it('non-admin cannot change disallowed groups', async function () { + await expectRevert( + this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: nonAdmin }), + 'missing role', + ); + }); + + it('single selector', async function () { + const receipt = await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { + from: admin, + }); + + expectEvent(receipt, 'GroupAllowed', { + target: this.managed.address, + selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4, + group, + allowed: false, + }); + + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([]); + + const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector); + expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([group]); + + await expectRevert(this.managed.restrictedFunction({ from: groupMember }), 'AccessManaged: authority rejected'); + + const otherRestricted = await this.managed.otherRestrictedFunction({ from: groupMember }); + expectEvent(otherRestricted, 'RestrictedRan'); + }); + + it('multiple selectors', async function () { + const receipt = await this.manager.setFunctionAllowedGroup( + this.managed.address, + [selector, otherSelector], + group, + false, + { from: admin }, + ); + + expectEvent(receipt, 'GroupAllowed', { + target: this.managed.address, + selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4 + group, + allowed: false, + }); + + expectEvent(receipt, 'GroupAllowed', { + target: this.managed.address, + selector: otherSelector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4 + group, + allowed: false, + }); + + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([]); + + const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector); + expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([]); + + await expectRevert(this.managed.restrictedFunction({ from: groupMember }), 'AccessManaged: authority rejected'); + await expectRevert( + this.managed.otherRestrictedFunction({ from: groupMember }), + 'AccessManaged: authority rejected', + ); + }); + + it('works on open target', async function () { + await this.manager.setContractModeOpen(this.managed.address, { from: admin }); + await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin }); + }); + + it('works on closed target', async function () { + await this.manager.setContractModeClosed(this.managed.address, { from: admin }); + await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin }); + }); + }); + + describe('modes', function () { + const group = '1'; + const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()'); + + beforeEach('deploying managed contract', async function () { + this.managed = await AccessManaged.new(this.manager.address); + await this.manager.createGroup('1', 'a group', { from: admin }); + await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, true, { from: admin }); + }); + + it('custom mode is default', async function () { + expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Custom); + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]); + }); + + it('open mode', async function () { + const receipt = await this.manager.setContractModeOpen(this.managed.address, { from: admin }); + expectEvent(receipt, 'AccessModeUpdated', { + target: this.managed.address, + mode: AccessMode.Open, + }); + expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Open); + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([PUBLIC_GROUP]); + }); + + it('closed mode', async function () { + const receipt = await this.manager.setContractModeClosed(this.managed.address, { from: admin }); + expectEvent(receipt, 'AccessModeUpdated', { + target: this.managed.address, + mode: AccessMode.Closed, + }); + expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Closed); + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([]); + }); + + it('mode cycle', async function () { + await this.manager.setContractModeOpen(this.managed.address, { from: admin }); + await this.manager.setContractModeClosed(this.managed.address, { from: admin }); + await this.manager.setContractModeCustom(this.managed.address, { from: admin }); + expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Custom); + const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector); + expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]); + }); + + it('non-admin cannot change mode', async function () { + await expectRevert(this.manager.setContractModeCustom(this.managed.address), 'missing role'); + await expectRevert(this.manager.setContractModeOpen(this.managed.address), 'missing role'); + await expectRevert(this.manager.setContractModeClosed(this.managed.address), 'missing role'); + }); + }); + + describe('transfering authority', function () { + beforeEach('deploying managed contract', async function () { + this.managed = await AccessManaged.new(this.manager.address); + }); + + it('admin can transfer authority', async function () { + await this.manager.transferContractAuthority(this.managed.address, otherAuthority, { from: admin }); + expect(await this.managed.authority()).to.equal(otherAuthority); + }); + + it('non-admin cannot transfer authority', async function () { + await expectRevert( + this.manager.transferContractAuthority(this.managed.address, otherAuthority, { from: nonAdmin }), + 'missing role', + ); + }); + }); + + describe('adapter', function () { + const group = '0'; + + beforeEach('deploying adapter', async function () { + await this.manager.createGroup(group, 'a group', { from: admin }); + await this.manager.grantGroup(group, user1, { from: admin }); + this.adapter = await AccessManagerAdapter.new(this.manager.address); + }); + + it('with ownable', async function () { + const target = await Ownable.new(); + await target.transferOwnership(this.adapter.address); + + const { data } = await target.$_checkOwner.request(); + const selector = data.slice(0, 10); + + await expectRevert( + this.adapter.relay(target.address, data, { from: user1 }), + 'AccessManagerAdapter: caller not allowed', + ); + + await this.manager.setFunctionAllowedGroup(target.address, [selector], group, true, { from: admin }); + await this.adapter.relay(target.address, data, { from: user1 }); + }); + + it('with access control', async function () { + const ROLE = web3.utils.soliditySha3('ROLE'); + const target = await AccessControl.new(); + await target.$_grantRole(ROLE, this.adapter.address); + + const { data } = await target.$_checkRole.request(ROLE); + const selector = data.slice(0, 10); + + await expectRevert( + this.adapter.relay(target.address, data, { from: user1 }), + 'AccessManagerAdapter: caller not allowed', + ); + + await this.manager.setFunctionAllowedGroup(target.address, [selector], group, true, { from: admin }); + await this.adapter.relay(target.address, data, { from: user1 }); + }); + + it('transfer authority', async function () { + await this.manager.transferContractAuthority(this.adapter.address, otherAuthority, { from: admin }); + expect(await this.adapter.authority()).to.equal(otherAuthority); + }); + }); +}); diff --git a/test/helpers/enums.js b/test/helpers/enums.js index ced6c3858dc..874d8375f9d 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -9,4 +9,5 @@ module.exports = { ProposalState: Enum('Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'), VoteType: Enum('Against', 'For', 'Abstain'), Rounding: Enum('Down', 'Up', 'Zero'), + AccessMode: Enum('Custom', 'Closed', 'Open'), };