From bf5786aae090a6535423336a945347fd1bff0b60 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Thu, 3 Aug 2023 02:24:51 -0300 Subject: [PATCH] Access Manager (#4416) --- contracts/access/manager/AccessManaged.sol | 99 ++ contracts/access/manager/AccessManager.sol | 616 ++++++++++++ contracts/access/manager/IAccessManager.sol | 120 +++ contracts/access/manager/IAuthority.sol | 37 + contracts/access/manager/IManaged.sol | 15 + .../manager/utils/AccessManagedAdapter.sol | 42 + .../extensions/GovernorTimelock.sol | 211 ++++ contracts/mocks/AccessManagedTarget.sol | 18 + contracts/utils/types/Time.sol | 131 +++ package-lock.json | 14 +- test/access/manager/AccessManager.test.js | 927 ++++++++++++++++++ .../utils/AccessManagedAdapter.test.js | 158 +++ test/helpers/enums.js | 1 + test/helpers/methods.js | 5 + 14 files changed, 2387 insertions(+), 7 deletions(-) create mode 100644 contracts/access/manager/AccessManaged.sol create mode 100644 contracts/access/manager/AccessManager.sol create mode 100644 contracts/access/manager/IAccessManager.sol create mode 100644 contracts/access/manager/IAuthority.sol create mode 100644 contracts/access/manager/IManaged.sol create mode 100644 contracts/access/manager/utils/AccessManagedAdapter.sol create mode 100644 contracts/governance/extensions/GovernorTimelock.sol create mode 100644 contracts/mocks/AccessManagedTarget.sol create mode 100644 contracts/utils/types/Time.sol create mode 100644 test/access/manager/AccessManager.test.js create mode 100644 test/access/manager/utils/AccessManagedAdapter.test.js create mode 100644 test/helpers/methods.js diff --git a/contracts/access/manager/AccessManaged.sol b/contracts/access/manager/AccessManaged.sol new file mode 100644 index 000000000..9496b3f1b --- /dev/null +++ b/contracts/access/manager/AccessManaged.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IAuthority, safeCanCall} from "./IAuthority.sol"; +import {IManaged} from "./IManaged.sol"; +import {Context} from "../../utils/Context.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 to access 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}. + */ +abstract contract AccessManaged is Context, IManaged { + address private _authority; + + /** + * @dev Initializes the contract connected to an initial authority. + */ + constructor(address initialAuthority) { + _setAuthority(initialAuthority); + } + + /** + * @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. + * ==== + * + * [NOTE] + * ==== + * Selector collisions are mitigated by scoping permissions per contract, but some edge cases must be considered: + * + * * If the https://docs.soliditylang.org/en/latest/contracts.html#receive-ether-function[`receive()`] function is restricted, + * any other function with a `0x00000000` selector will share permissions with `receive()`. + * * Similarly, if there's no `receive()` function but a `fallback()` instead, the fallback might be called with empty `calldata`, + * sharing the `0x00000000` selector permissions as well. + * * For any other selector, if the restricted function is set on an upgradeable contract, an upgrade may remove the restricted + * function and replace it with a new method whose selector replaces the last one, keeping the previous permissions. + * ==== + */ + modifier restricted() { + _checkCanCall(_msgSender(), address(this), msg.sig); + _; + } + + /** + * @dev Returns the current authority. + */ + function authority() public view virtual returns (address) { + return _authority; + } + + /** + * @dev Transfers control to a new authority. The caller must be the current authority. + */ + function setAuthority(address newAuthority) public virtual { + address caller = _msgSender(); + if (caller != authority()) { + revert AccessManagedUnauthorized(caller); + } + if (newAuthority.code.length == 0) { + revert AccessManagedInvalidAuthority(newAuthority); + } + _setAuthority(newAuthority); + } + + /** + * @dev Transfers control to a new authority. Internal function with no access restriction. + */ + function _setAuthority(address newAuthority) internal virtual { + _authority = newAuthority; + emit AuthorityUpdated(newAuthority); + } + + /** + * @dev Reverts if the caller is not allowed to call the function identified by a selector. + */ + function _checkCanCall(address caller, address target, bytes4 selector) internal view virtual { + (bool allowed, uint32 delay) = safeCanCall(authority(), caller, target, selector); + if (!allowed) { + if (delay > 0) { + revert AccessManagedRequiredDelay(caller, delay); + } else { + revert AccessManagedUnauthorized(caller); + } + } + } +} diff --git a/contracts/access/manager/AccessManager.sol b/contracts/access/manager/AccessManager.sol new file mode 100644 index 000000000..b4ef5e8fb --- /dev/null +++ b/contracts/access/manager/AccessManager.sol @@ -0,0 +1,616 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IAccessManager} from "./IAccessManager.sol"; +import {IManaged} from "./IManaged.sol"; +import {IAuthority} from "./IAuthority.sol"; +import {AccessManagedAdapter} from "./utils/AccessManagedAdapter.sol"; +import {Address} from "../../utils/Address.sol"; +import {Context} from "../../utils/Context.sol"; +import {Multicall} from "../../utils/Multicall.sol"; +import {Time} from "../../utils/types/Time.sol"; + +/** + * @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. Accounts can be added into any number of these groups. Each of + * them defines a 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 where functions are mapped to groups are said to be in a "custom" mode, but 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 + * they will be highly secured (e.g., a multisig or a well-configured DAO). + * + * NOTE: This contract implements a form of the {IAuthority} interface, but {canCall} has additional return data so it + * doesn't inherit `IAuthority`. It is however compatible with the `IAuthority` interface since the first 32 bytes of + * the return data are a boolean as expected by that interface. + */ +contract AccessManager is Context, Multicall, IAccessManager { + using Time for *; + + uint256 public constant ADMIN_GROUP = type(uint256).min; // 0 + uint256 public constant PUBLIC_GROUP = type(uint256).max; // 2**256-1 + + mapping(address target => AccessMode mode) private _contractMode; + mapping(address target => mapping(bytes4 selector => uint256 groupId)) private _allowedGroups; + mapping(uint256 groupId => Group) private _groups; + mapping(bytes32 operationId => uint48 schedule) private _schedules; + + // This should be transcient storage when supported by the EVM. + bytes32 private _relayIdentifier; + + /** + * @dev Check that the caller has a given permission level (`groupId`). Note that this does NOT consider execution + * delays that may be associated to that group. + */ + modifier onlyGroup(uint256 groupId) { + address msgsender = _msgSender(); + if (!hasGroup(groupId, msgsender)) { + revert AccessControlUnauthorizedAccount(msgsender, groupId); + } + _; + } + + constructor(address initialAdmin) { + // admin is active immediately and without any execution delay. + _grantGroup(ADMIN_GROUP, initialAdmin, 0, 0); + } + + // =================================================== GETTERS ==================================================== + /** + * @dev Check if an address (`caller`) is authorised to call a given function on a given contract directly (with + * no restriction). Additionally, it returns the delay needed to perform the call indirectly through the {schedule} + * & {relay} workflow. + * + * This function is usually called by the targeted contract to control immediate execution of restricted functions. + * Therefore we only return true is the call can be performed without any delay. If the call is subject to a delay, + * then the function should return false, and the caller should schedule the operation for future execution. + * + * We may be able to hash the operation, and check if the call was scheduled, but we would not be able to cleanup + * the schedule, leaving the possibility of multiple executions. Maybe this function should not be view? + * + * NOTE: The IAuthority interface does not include the `uint32` delay. This is an extension of that interface that + * is backward compatible. Some contract may thus ignore the second return argument. In that case they will fail + * to identify the indirect workflow, and will consider call that require a delay to be forbidden. + */ + function canCall(address caller, address target, bytes4 selector) public view virtual returns (bool, uint32) { + AccessMode mode = getContractMode(target); + if (mode == AccessMode.Open) { + return (true, 0); + } else if (mode == AccessMode.Closed) { + return (false, 0); + } else if (caller == address(this)) { + // Caller is AccessManager => call was relayed. In that case the relay already checked permissions. We + // verify that the call "identifier", which is set during the relay call, is correct. + return (_relayIdentifier == keccak256(abi.encodePacked(target, selector)), 0); + } else { + uint256 groupId = getFunctionAllowedGroup(target, selector); + bool inGroup = hasGroup(groupId, caller); + uint32 executeDelay = inGroup ? getAccess(groupId, caller).delay.get() : 0; + return (inGroup && executeDelay == 0, executeDelay); + } + } + + /** + * @dev Get the mode under which a contract is operating. + */ + function getContractMode(address target) public view virtual returns (AccessMode) { + return _contractMode[target]; + } + + /** + * @dev Get the permission level (group) required to call a function. This only applies for contract that are + * operating under the `Custom` mode. + */ + function getFunctionAllowedGroup(address target, bytes4 selector) public view virtual returns (uint256) { + return _allowedGroups[target][selector]; + } + + /** + * @dev Get the id of the group that acts as an admin for given group. + * + * The admin permission is required to grant the group, revoke the group and update the execution delay to execute + * an operation that is restricted to this group. + */ + function getGroupAdmin(uint256 groupId) public view virtual returns (uint256) { + return _groups[groupId].admin; + } + + /** + * @dev Get the group that acts as a guardian for a given group. + * + * The guardian permission allows canceling operations that have been scheduled under the group. + */ + function getGroupGuardian(uint256 groupId) public view virtual returns (uint256) { + return _groups[groupId].guardian; + } + + /** + * @dev Get the group current grant delay, that value may change at any point, without an event emitted, following + * a call to {setGrantDelay}. Changes to this value, including effect timepoint are notified by the + * {GroupGrantDelayChanged} event. + */ + function getGroupGrantDelay(uint256 groupId) public view virtual returns (uint32) { + return _groups[groupId].delay.get(); + } + + /** + * @dev Get the access details for a given account in a given group. These details include the timepoint at which + * membership becomes active, and the delay applied to all operation by this user that require this permission + * level. + */ + function getAccess(uint256 groupId, address account) public view virtual returns (Access memory) { + return _groups[groupId].members[account]; + } + + /** + * @dev Check if a given account currently had the permission level corresponding to a given group. Note that this + * permission might be associated with a delay. {getAccess} can provide more details. + */ + function hasGroup(uint256 groupId, address account) public view virtual returns (bool) { + return groupId == PUBLIC_GROUP || getAccess(groupId, account).since.isSetAndPast(Time.timestamp()); + } + + // =============================================== GROUP MANAGEMENT =============================================== + /** + * @dev Give a label to a group, for improved group discoverabily by UIs. + * + * Emits a {GroupLabel} event. + */ + function labelGroup(uint256 groupId, string calldata label) public virtual onlyGroup(ADMIN_GROUP) { + emit GroupLabel(groupId, label); + } + + /** + * @dev Give permission to an account to execute function restricted to a group. Optionally, a delay can be + * enforced for any function call, byt this user, that require this level of permission. This call is only + * effective after a grant delay that is specific to the group being granted. + * + * Requirements: + * + * - the caller must be in the group's admins + * + * Emits a {GroupGranted} event + */ + function grantGroup( + uint256 groupId, + address account, + uint32 executionDelay + ) public virtual onlyGroup(getGroupAdmin(groupId)) { + _grantGroup(groupId, account, getGroupGrantDelay(groupId), executionDelay); + } + + /** + * @dev Remove an account for a group, with immediate effect. + * + * Requirements: + * + * - the caller must be in the group's admins + * + * Emits a {GroupRevoked} event + */ + function revokeGroup(uint256 groupId, address account) public virtual onlyGroup(getGroupAdmin(groupId)) { + _revokeGroup(groupId, account); + } + + /** + * @dev Renounce group permissions for the calling account, with immediate effect. + * + * Requirements: + * + * - the caller must be `callerConfirmation`. + * + * Emits a {GroupRevoked} event + */ + function renounceGroup(uint256 groupId, address callerConfirmation) public virtual { + if (callerConfirmation != _msgSender()) { + revert AccessManagerBadConfirmation(); + } + _revokeGroup(groupId, callerConfirmation); + } + + /** + * @dev Set the execution delay for a given account in a given group. This update is not immediate and follows the + * delay rules. For example, If a user currently has a delay of 3 hours, and this is called to reduce that delay to + * 1 hour, the new delay will take some time to take effect, enforcing that any operation executed in the 3 hours + * that follows this update was indeed scheduled before this update. + * + * Requirements: + * + * - the caller must be in the group's admins + * + * Emits a {GroupExecutionDelayUpdate} event + */ + function setExecuteDelay( + uint256 groupId, + address account, + uint32 newDelay + ) public virtual onlyGroup(getGroupAdmin(groupId)) { + _setExecuteDelay(groupId, account, newDelay); + } + + /** + * @dev Change admin group for a given group. + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {GroupAdminChanged} event + */ + function setGroupAdmin(uint256 groupId, uint256 admin) public virtual onlyGroup(ADMIN_GROUP) { + _setGroupAdmin(groupId, admin); + } + + /** + * @dev Change guardian group for a given group. + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {GroupGuardianChanged} event + */ + function setGroupGuardian(uint256 groupId, uint256 guardian) public virtual onlyGroup(ADMIN_GROUP) { + _setGroupGuardian(groupId, guardian); + } + + /** + * @dev Update the . + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {GroupGrantDelayChanged} event + */ + function setGrantDelay(uint256 groupId, uint32 newDelay) public virtual onlyGroup(ADMIN_GROUP) { + _setGrantDelay(groupId, newDelay); + } + + /** + * @dev Internal version of {grantGroup} without access control. + * + * Emits a {GroupGranted} event + */ + function _grantGroup(uint256 groupId, address account, uint32 grantDelay, uint32 executionDelay) internal virtual { + if (groupId == PUBLIC_GROUP) { + revert AccessManagerLockedGroup(groupId); + } else if (_groups[groupId].members[account].since != 0) { + revert AccessManagerAcountAlreadyInGroup(groupId, account); + } + + uint48 since = Time.timestamp() + grantDelay; + _groups[groupId].members[account] = Access({since: since, delay: executionDelay.toDelay()}); + + emit GroupGranted(groupId, account, since, executionDelay); + } + + /** + * @dev Internal version of {revokeGroup} without access control. This logic is also used by {renounceGroup}. + * + * Emits a {GroupRevoked} event + */ + function _revokeGroup(uint256 groupId, address account) internal virtual { + if (groupId == PUBLIC_GROUP) { + revert AccessManagerLockedGroup(groupId); + } else if (_groups[groupId].members[account].since == 0) { + revert AccessManagerAcountNotInGroup(groupId, account); + } + + delete _groups[groupId].members[account]; + + emit GroupRevoked(groupId, account); + } + + /** + * @dev Internal version of {setExecuteDelay} without access control. + * + * Emits a {GroupExecutionDelayUpdate} event + */ + function _setExecuteDelay(uint256 groupId, address account, uint32 newDuration) internal virtual { + if (groupId == PUBLIC_GROUP) { + revert AccessManagerLockedGroup(groupId); + } else if (_groups[groupId].members[account].since == 0) { + revert AccessManagerAcountNotInGroup(groupId, account); + } + + Time.Delay newDelay = _groups[groupId].members[account].delay.update(newDuration, 0); // TODO: minsetback ? + _groups[groupId].members[account].delay = newDelay; + + (, , uint48 effectPoint) = newDelay.split(); + emit GroupExecutionDelayUpdate(groupId, account, newDuration, effectPoint); + } + + /** + * @dev Internal version of {setGroupAdmin} without access control. + * + * Emits a {GroupAdminChanged} event + */ + function _setGroupAdmin(uint256 groupId, uint256 admin) internal virtual { + if (groupId == ADMIN_GROUP || groupId == PUBLIC_GROUP) { + revert AccessManagerLockedGroup(groupId); + } + + _groups[groupId].admin = admin; + + emit GroupAdminChanged(groupId, admin); + } + + /** + * @dev Internal version of {setGroupGuardian} without access control. + * + * Emits a {GroupGuardianChanged} event + */ + function _setGroupGuardian(uint256 groupId, uint256 guardian) internal virtual { + if (groupId == ADMIN_GROUP || groupId == PUBLIC_GROUP) { + revert AccessManagerLockedGroup(groupId); + } + + _groups[groupId].guardian = guardian; + + emit GroupGuardianChanged(groupId, guardian); + } + + /** + * @dev Internal version of {setGrantDelay} without access control. + * + * Emits a {GroupGrantDelayChanged} event + */ + function _setGrantDelay(uint256 groupId, uint32 newDelay) internal virtual { + if (groupId == PUBLIC_GROUP) { + revert AccessManagerLockedGroup(groupId); + } + + Time.Delay updated = _groups[groupId].delay.update(newDelay, 0); // TODO: minsetback ? + _groups[groupId].delay = updated; + + (, , uint48 effect) = updated.split(); + emit GroupGrantDelayChanged(groupId, newDelay, effect); + } + + // ============================================= FUNCTION MANAGEMENT ============================================== + /** + * @dev Set the level of permission (`group`) required to call functions identified by the `selectors` in the + * `target` contract. + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {FunctionAllowedGroupUpdated} event per selector + */ + function setFunctionAllowedGroup( + address target, + bytes4[] calldata selectors, + uint256 groupId + ) public virtual onlyGroup(ADMIN_GROUP) { + // todo set delay or document risks + for (uint256 i = 0; i < selectors.length; ++i) { + _setFunctionAllowedGroup(target, selectors[i], groupId); + } + } + + /** + * @dev Internal version of {setFunctionAllowedGroup} without access control. + * + * Emits a {FunctionAllowedGroupUpdated} event + */ + function _setFunctionAllowedGroup(address target, bytes4 selector, uint256 groupId) internal virtual { + _allowedGroups[target][selector] = groupId; + emit FunctionAllowedGroupUpdated(target, selector, groupId); + } + + // =============================================== MODE MANAGEMENT ================================================ + /** + * @dev Set the operating mode of a contract to Custom. This enables the group mechanism for per-function access + * restriction and delay enforcement. + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {AccessModeUpdated} event. + */ + function setContractModeCustom(address target) public virtual onlyGroup(ADMIN_GROUP) { + // todo set delay or document risks + _setContractMode(target, AccessMode.Custom); + } + + /** + * @dev Set the operating mode of a contract to Open. This allows anyone to call any `restricted()` function with + * no delay. + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {AccessModeUpdated} event. + */ + function setContractModeOpen(address target) public virtual onlyGroup(ADMIN_GROUP) { + // todo set delay or document risks + _setContractMode(target, AccessMode.Open); + } + + /** + * @dev Set the operating mode of a contract to Close. This prevents anyone from calling any `restricted()` + * function. + * + * Requirements: + * + * - the caller must be a global admin + * + * Emits a {AccessModeUpdated} event. + */ + function setContractModeClosed(address target) public virtual onlyGroup(ADMIN_GROUP) { + // todo set delay or document risks + _setContractMode(target, AccessMode.Closed); + } + + /** + * @dev Set the operating mode of a contract. This is an internal setter with no access restrictions. + * + * Emits a {AccessModeUpdated} event. + */ + function _setContractMode(address target, AccessMode mode) internal virtual { + _contractMode[target] = mode; + emit AccessModeUpdated(target, mode); + } + + // ============================================== DELAYED OPERATIONS ============================================== + /** + * @dev Return the timepoint at which a scheduled operation will be ready for execution. This returns 0 if the + * operation is not yet scheduled, was executed or was canceled. + */ + function getSchedule(bytes32 id) public view virtual returns (uint48) { + return _schedules[id]; + } + + /** + * @dev Schedule a delayed operation, and return the operation identifier. + * + * Emits a {Scheduled} event. + */ + function schedule(address target, bytes calldata data) public virtual returns (bytes32) { + address caller = _msgSender(); + bytes4 selector = bytes4(data[0:4]); + + // Fetch restriction to that apply to the caller on the targeted function + (bool allowed, uint32 setback) = canCall(caller, target, selector); + + // If caller is not authorised, revert + if (!allowed && setback == 0) { + revert AccessManagerUnauthorizedCall(caller, target, selector); + } + + // If caller is authorised, schedule operation + bytes32 operationId = _hashOperation(caller, target, data); + if (_schedules[operationId] != 0) { + revert AccessManagerAlreadyScheduled(operationId); + } + _schedules[operationId] = Time.timestamp() + setback; + + emit Scheduled(operationId, caller, target, data); + return operationId; + } + + /** + * @dev Execute a function that is delay restricted, provided it was properly scheduled beforehand, or the + * execution delay is 0. + * + * Emits a {Executed} event if the call was scheduled. Unscheduled call (with no delay) do not emit that event. + */ + function relay(address target, bytes calldata data) public payable virtual { + relayViaAdapter(target, data, address(0)); + } + + /** + * @dev Execute a function that is delay restricted in the same way as {relay} but through an + * {AccessManagedAdapter}. + */ + function relayViaAdapter(address target, bytes calldata data, address adapter) public payable virtual { + address caller = _msgSender(); + bytes4 selector = bytes4(data[0:4]); + + // Fetch restriction to that apply to the caller on the targeted function + (bool allowed, uint32 setback) = canCall(caller, target, selector); + + // If caller is not authorised, revert + if (!allowed && setback == 0) { + revert AccessManagerUnauthorizedCall(caller, target, selector); + } + + // If caller is authorised, check operation was scheduled early enough + bytes32 operationId = _hashOperation(caller, target, data); + uint48 timepoint = _schedules[operationId]; + if (setback != 0) { + if (timepoint == 0) { + revert AccessManagerNotScheduled(operationId); + } else if (timepoint > Time.timestamp()) { + revert AccessManagerNotReady(operationId); + } + } + if (timepoint != 0) { + delete _schedules[operationId]; + emit Executed(operationId); + } + + // Mark the target and selector as authorised + bytes32 relayIdentifierBefore = _relayIdentifier; + _relayIdentifier = keccak256(abi.encodePacked(target, selector)); + + if (adapter != address(0)) { + // Perform call through adapter + AccessManagedAdapter(adapter).relay{value: msg.value}(target, data); + } else { + // Perform call directly + Address.functionCallWithValue(target, data, msg.value); + } + + // Reset relay identifier + _relayIdentifier = relayIdentifierBefore; + } + + /** + * @dev Cancel a scheduled (delayed) operation. + * + * Requirements: + * + * - the caller must be the proposer, or a guardian of the targeted function + * + * Emits a {Canceled} event. + */ + function cancel(address caller, address target, bytes calldata data) public virtual { + address msgsender = _msgSender(); + bytes4 selector = bytes4(data[0:4]); + + bytes32 operationId = _hashOperation(caller, target, data); + if (_schedules[operationId] == 0) { + revert AccessManagerNotScheduled(operationId); + } else if ( + caller != msgsender && + !hasGroup(ADMIN_GROUP, msgsender) && + !hasGroup(getGroupGuardian(getFunctionAllowedGroup(target, selector)), msgsender) + ) { + // calls can only be canceled by the account that scheduled them, a global admin, or by a guardian of the required group. + revert AccessManagerCannotCancel(msgsender, caller, target, selector); + } + + delete _schedules[operationId]; + emit Canceled(operationId); + } + + /** + * @dev Hashing function for delayed operations + */ + function _hashOperation(address caller, address target, bytes calldata data) private pure returns (bytes32) { + return keccak256(abi.encode(caller, target, data)); + } + + // ==================================================== OTHERS ==================================================== + /** + * @dev Change the AccessManager instance used by a contract that correctly uses this instance. + * + * Requirements: + * + * - the caller must be a global admin + */ + function updateAuthority(IManaged target, address newAuthority) public virtual onlyGroup(ADMIN_GROUP) { + // todo set delay or document risks + target.setAuthority(newAuthority); + } +} diff --git a/contracts/access/manager/IAccessManager.sol b/contracts/access/manager/IAccessManager.sol new file mode 100644 index 000000000..cb5604ec8 --- /dev/null +++ b/contracts/access/manager/IAccessManager.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IManaged} from "./IManaged.sol"; +import {Time} from "../../utils/types/Time.sol"; + +interface IAccessManager { + enum AccessMode { + Custom, + Closed, + Open + } + + // Structure that stores the details for a group/account pair. This structures fit into a single slot. + struct Access { + // Timepoint at which the user gets the permission. If this is either 0, or in the future, the group permission + // are not available. Should be checked using {Time-isSetAndPast} + uint48 since; + // delay for execution. Only applies to restricted() / relay() calls. This does not restrict access to + // functions that use the `onlyGroup` modifier. + Time.Delay delay; + } + + // Structure that stores the details of a group, including: + // - the members of the group + // - the admin group (that can grant or revoke permissions) + // - the guardian group (that can cancel operations targeting functions that need this group + // - the grand delay + struct Group { + mapping(address user => Access access) members; + uint256 admin; + uint256 guardian; + Time.Delay delay; // delay for granting + } + + /** + * @dev A delay operation was schedule. + */ + event Scheduled(bytes32 operationId, address caller, address target, bytes data); + + /** + * @dev A scheduled operation was executed. + */ + event Executed(bytes32 operationId); + + /** + * @dev A scheduled operation was canceled. + */ + event Canceled(bytes32 operationId); + + event GroupLabel(uint256 indexed groupId, string label); + event GroupGranted(uint256 indexed groupId, address indexed account, uint48 since, uint32 delay); + event GroupRevoked(uint256 indexed groupId, address indexed account); + event GroupExecutionDelayUpdate(uint256 indexed groupId, address indexed account, uint32 delay, uint48 from); + event GroupAdminChanged(uint256 indexed groupId, uint256 indexed admin); + event GroupGuardianChanged(uint256 indexed groupId, uint256 indexed guardian); + event GroupGrantDelayChanged(uint256 indexed groupId, uint32 delay, uint48 from); + event AccessModeUpdated(address indexed target, AccessMode mode); + event FunctionAllowedGroupUpdated(address indexed target, bytes4 selector, uint256 indexed groupId); + + error AccessManagerAlreadyScheduled(bytes32 operationId); + error AccessManagerNotScheduled(bytes32 operationId); + error AccessManagerNotReady(bytes32 operationId); + error AccessManagerLockedGroup(uint256 groupId); + error AccessManagerAcountAlreadyInGroup(uint256 groupId, address account); + error AccessManagerAcountNotInGroup(uint256 groupId, address account); + error AccessManagerBadConfirmation(); + error AccessControlUnauthorizedAccount(address msgsender, uint256 groupId); + error AccessManagerUnauthorizedCall(address caller, address target, bytes4 selector); + error AccessManagerCannotCancel(address msgsender, address caller, address target, bytes4 selector); + + function canCall( + address caller, + address target, + bytes4 selector + ) external view returns (bool allowed, uint32 delay); + + function getContractMode(address target) external view returns (AccessMode); + + function getFunctionAllowedGroup(address target, bytes4 selector) external view returns (uint256); + + function getGroupAdmin(uint256 group) external view returns (uint256); + + function getGroupGuardian(uint256 group) external view returns (uint256); + + function getGroupGrantDelay(uint256 groupId) external view returns (uint32); + + function getAccess(uint256 group, address account) external view returns (Access memory); + + function hasGroup(uint256 group, address account) external view returns (bool); + + function grantGroup(uint256 group, address account, uint32 executionDelay) external; + + function revokeGroup(uint256 group, address account) external; + + function renounceGroup(uint256 group, address callerConfirmation) external; + + function setExecuteDelay(uint256 group, address account, uint32 newDelay) external; + + function setGroupAdmin(uint256 group, uint256 admin) external; + + function setGroupGuardian(uint256 group, uint256 guardian) external; + + function setGrantDelay(uint256 group, uint32 newDelay) external; + + function setContractModeCustom(address target) external; + + function setContractModeOpen(address target) external; + + function setContractModeClosed(address target) external; + + function schedule(address target, bytes calldata data) external returns (bytes32); + + function cancel(address caller, address target, bytes calldata data) external; + + function relay(address target, bytes calldata data) external payable; + + function updateAuthority(IManaged target, address newAuthority) external; +} diff --git a/contracts/access/manager/IAuthority.sol b/contracts/access/manager/IAuthority.sol new file mode 100644 index 000000000..d016906f1 --- /dev/null +++ b/contracts/access/manager/IAuthority.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +/** + * @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); +} + +/** + * @dev Since `AccessManager` implements an extended IAuthority interface, invoking `canCall` with backwards compatibility + * for the preexisting `IAuthority` interface requires special care to avoid reverting on insufficient return data. + * This helper function takes care of invoking `canCall` in a backwards compatible way without reverting. + */ +function safeCanCall( + address authority, + address caller, + address target, + bytes4 selector +) view returns (bool allowed, uint32 delay) { + (bool success, bytes memory data) = authority.staticcall( + abi.encodeCall(IAuthority.canCall, (caller, target, selector)) + ); + if (success) { + if (data.length >= 0x40) { + (allowed, delay) = abi.decode(data, (bool, uint32)); + } else if (data.length >= 0x20) { + allowed = abi.decode(data, (bool)); + } + } + return (allowed, delay); +} diff --git a/contracts/access/manager/IManaged.sol b/contracts/access/manager/IManaged.sol new file mode 100644 index 000000000..3c98491f7 --- /dev/null +++ b/contracts/access/manager/IManaged.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +interface IManaged { + event AuthorityUpdated(address authority); + + error AccessManagedUnauthorized(address caller); + error AccessManagedRequiredDelay(address caller, uint32 delay); + error AccessManagedInvalidAuthority(address authority); + + function authority() external view returns (address); + + function setAuthority(address) external; +} diff --git a/contracts/access/manager/utils/AccessManagedAdapter.sol b/contracts/access/manager/utils/AccessManagedAdapter.sol new file mode 100644 index 000000000..e22c53635 --- /dev/null +++ b/contracts/access/manager/utils/AccessManagedAdapter.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {AccessManaged} from "../AccessManaged.sol"; +import {Address} from "../../../utils/Address.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 AccessManagedAdapter is AccessManaged { + error AccessManagedAdapterUnauthorizedSelfRelay(); + + /** + * @dev Initializes an adapter connected to an AccessManager instance. + */ + constructor(address initialAuthority) AccessManaged(initialAuthority) {} + + /** + * @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 a member of the group that is + * allowed for the function. + */ + function relay(address target, bytes calldata data) external payable { + if (target == address(this)) { + revert AccessManagedAdapterUnauthorizedSelfRelay(); + } + + _checkCanCall(_msgSender(), target, bytes4(data[0:4])); + + Address.functionCallWithValue(target, data, msg.value); + } +} diff --git a/contracts/governance/extensions/GovernorTimelock.sol b/contracts/governance/extensions/GovernorTimelock.sol new file mode 100644 index 000000000..db3b0320d --- /dev/null +++ b/contracts/governance/extensions/GovernorTimelock.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IGovernorTimelock} from "./IGovernorTimelock.sol"; +import {IGovernor, Governor} from "../Governor.sol"; +import {IManaged} from "../../access/manager/IManaged.sol"; +import {IAuthority, safeCanCall} from "../../access/manager/IAuthority.sol"; +import {IAccessManager} from "../../access/manager/IAccessManager.sol"; +import {Address} from "../../utils/Address.sol"; +import {Math} from "../../utils/math/Math.sol"; + +/** + * @dev TODO + * + * _Available since v5.0._ + */ +abstract contract GovernorTimelock is IGovernorTimelock, Governor { + struct ExecutionDetail { + address authority; + uint32 delay; + } + + mapping(uint256 => ExecutionDetail[]) private _executionDetails; + mapping(uint256 => uint256) private _proposalEta; + + /** + * @dev Overridden version of the {Governor-state} function with added support for the `Queued` and `Expired` state. + */ + function state(uint256 proposalId) public view virtual override(IGovernor, Governor) returns (ProposalState) { + ProposalState currentState = super.state(proposalId); + + if (currentState == ProposalState.Succeeded && proposalEta(proposalId) != 0) { + return ProposalState.Queued; + } else { + return currentState; + } + } + + /** + * @dev Public accessor to check the eta of a queued proposal. + */ + function proposalEta(uint256 proposalId) public view virtual override returns (uint256) { + return _proposalEta[proposalId]; + } + + /** + * @dev Public accessor to check the execution details. + */ + function proposalExecutionDetails(uint256 proposalId) public view returns (ExecutionDetail[] memory) { + return _executionDetails[proposalId]; + } + + /** + * @dev See {IGovernor-propose} + */ + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public virtual override(IGovernor, Governor) returns (uint256) { + uint256 proposalId = super.propose(targets, values, calldatas, description); + + ExecutionDetail[] storage details = _executionDetails[proposalId]; + for (uint256 i = 0; i < targets.length; ++i) { + details.push(_detectExecutionDetails(targets[i], bytes4(calldatas[i]))); + } + + return proposalId; + } + + /** + * @dev Function to queue a proposal to the timelock. + * + * NOTE: execution delay is estimated based on the delay information retrieved in {proposal}. This value may be + * off if the delay were updated during the vote. + */ + function queue( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public virtual override returns (uint256) { + uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash); + + ProposalState currentState = state(proposalId); + if (currentState != ProposalState.Succeeded) { + revert GovernorUnexpectedProposalState( + proposalId, + currentState, + _encodeStateBitmap(ProposalState.Succeeded) + ); + } + + ExecutionDetail[] storage details = _executionDetails[proposalId]; + ExecutionDetail memory detail; + uint32 setback = 0; + + for (uint256 i = 0; i < targets.length; ++i) { + detail = details[i]; + if (detail.authority != address(0)) { + IAccessManager(detail.authority).schedule(targets[i], calldatas[i]); + } + setback = uint32(Math.max(setback, detail.delay)); // cast is safe, both parameters are uint32 + } + + uint256 eta = block.timestamp + setback; + _proposalEta[proposalId] = eta; + + return eta; + } + + /** + * @dev See {IGovernor-_execute} + */ + function _execute( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 /*descriptionHash*/ + ) internal virtual override { + ExecutionDetail[] storage details = _executionDetails[proposalId]; + ExecutionDetail memory detail; + + // TODO: enforce ETA (might include some _defaultDelaySeconds that are not enforced by any authority) + + for (uint256 i = 0; i < targets.length; ++i) { + detail = details[i]; + if (detail.authority != address(0)) { + IAccessManager(detail.authority).relay{value: values[i]}(targets[i], calldatas[i]); + } else { + (bool success, bytes memory returndata) = targets[i].call{value: values[i]}(calldatas[i]); + Address.verifyCallResult(success, returndata); + } + } + + delete _executionDetails[proposalId]; + } + + /** + * @dev See {IGovernor-_cancel} + */ + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal virtual override returns (uint256) { + uint256 proposalId = super._cancel(targets, values, calldatas, descriptionHash); + + ExecutionDetail[] storage details = _executionDetails[proposalId]; + ExecutionDetail memory detail; + + for (uint256 i = 0; i < targets.length; ++i) { + detail = details[i]; + if (detail.authority != address(0)) { + IAccessManager(detail.authority).cancel(address(this), targets[i], calldatas[i]); + } + } + + delete _executionDetails[proposalId]; + + return proposalId; + } + + /** + * @dev Default delay to apply to function calls that are not (scheduled and) executed through an AccessManager. + * + * NOTE: execution delays are processed by the AccessManager contracts. We expect these to always be in seconds. + * Therefore, the default delay that is enforced for calls that don't go through an access manager is also in + * seconds, regardless of the Governor's clock mode. + */ + function _defaultDelaySeconds() internal view virtual returns (uint32) { + return 0; + } + + /** + * @dev Check if the execution of a call needs to be performed through an AccessManager and what delay should be + * applied to this call. + * + * Returns { manager: address(0), delay: _defaultDelaySeconds() } if: + * - target does not have code + * - target does not implement IManaged + * - calling canCall on the target's manager returns a 0 delay + * - calling canCall on the target's manager reverts + * Otherwise (calling canCall on the target's manager returns a non 0 delay), return the address of the + * AccessManager to use, and the delay for this call. + */ + function _detectExecutionDetails(address target, bytes4 selector) private view returns (ExecutionDetail memory) { + bool success; + bytes memory returndata; + + // Get authority + (success, returndata) = target.staticcall(abi.encodeCall(IManaged.authority, ())); + if (success && returndata.length >= 0x20) { + address authority = abi.decode(returndata, (address)); + + // Check if governor can call, and try to detect a delay + (bool authorized, uint32 delay) = safeCanCall(authority, address(this), target, selector); + + // If direct call is not authorized, and delayed call is possible + if (!authorized && delay > 0) { + return ExecutionDetail({authority: authority, delay: delay}); + } + } + + return ExecutionDetail({authority: address(0), delay: _defaultDelaySeconds()}); + } +} diff --git a/contracts/mocks/AccessManagedTarget.sol b/contracts/mocks/AccessManagedTarget.sol new file mode 100644 index 000000000..305c989f6 --- /dev/null +++ b/contracts/mocks/AccessManagedTarget.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {AccessManaged} from "../access/manager/AccessManaged.sol"; + +abstract contract AccessManagedTarget is AccessManaged { + event CalledRestricted(address caller); + event CalledUnrestricted(address caller); + + function fnRestricted() public restricted { + emit CalledRestricted(msg.sender); + } + + function fnUnrestricted() public { + emit CalledUnrestricted(msg.sender); + } +} diff --git a/contracts/utils/types/Time.sol b/contracts/utils/types/Time.sol new file mode 100644 index 000000000..277bc155d --- /dev/null +++ b/contracts/utils/types/Time.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Math} from "../math/Math.sol"; +import {SafeCast} from "../math/SafeCast.sol"; + +/** + * @dev This library provides helpers for manipulating time-related objects. + * + * It uses the following types: + * - `uint48` for timepoints + * - `uint32` for durations + * + * While the library doesn't provide specific types for timepoints and duration, it does provide: + * - a `Delay` type to represent duration that can be programmed to change value automatically at a given point + * - additional helper functions + */ +library Time { + using Time for *; + + /** + * @dev Get the block timestamp as a Timepoint + */ + function timestamp() internal view returns (uint48) { + return SafeCast.toUint48(block.timestamp); + } + + /** + * @dev Get the block number as a Timepoint + */ + function blockNumber() internal view returns (uint48) { + return SafeCast.toUint48(block.number); + } + + /** + * @dev Check if a timepoint is set, and in the past + */ + function isSetAndPast(uint48 timepoint, uint48 ref) internal pure returns (bool) { + return timepoint != 0 && timepoint <= ref; + } + + // ==================================================== Delay ===================================================== + /** + * @dev A `Delay` is a uint32 duration that can be programmed to change value automatically at a given point in the + * future. The "effect" timepoint describes when the transitions happens from the "old" value to the "new" value. + * This allows updating the delay applied to some operation while keeping so guarantees. + * + * In particular, the {update} function guarantees that is the delay is reduced, the old delay still applies for + * some time. For example if the delay is currently 7 days to do an upgrade, the admin should not be able to set + * the delay to 0 and upgrade immediately. If the admin wants to reduce the delay, the old delay (7 days) should + * still apply for some time. + * + * + * The `Delay` type is 128 bits long, and packs the following: + * [000:031] uint32 for the current value (duration) + * [032:063] uint32 for the pending value (duration) + * [064:111] uint48 for the effect date (timepoint) + * + * NOTE: The {get} and {update} function operate using timestamps. Block number based delays should use the + * {getAt} and {updateAt} variants of these functions. + */ + type Delay is uint112; + + /** + * @dev Wrap a Duration into a Delay to add the one-step "update in the future" feature + */ + function toDelay(uint32 duration) internal pure returns (Delay) { + return Delay.wrap(duration); + } + + /** + * @dev Get the value the Delay will be at a given timepoint + */ + function getAt(Delay self, uint48 timepoint) internal pure returns (uint32) { + (uint32 oldValue, uint32 newValue, uint48 effect) = self.split(); + return (effect == 0 || effect > timepoint) ? oldValue : newValue; + } + + /** + * @dev Get the current value. + */ + function get(Delay self) internal view returns (uint32) { + return self.getAt(timestamp()); + } + + /** + * @dev Get the pending value, and effect timepoint. If the effect timepoint is 0, then the pending value should + * not be considered. + */ + function getPending(Delay self) internal pure returns (uint32, uint48) { + (, uint32 newValue, uint48 effect) = self.split(); + return (newValue, effect); + } + + /** + * @dev Update a Delay object so that a new duration takes effect at a given timepoint. + */ + function updateAt(Delay self, uint32 newValue, uint48 effect) internal view returns (Delay) { + return pack(self.get(), newValue, effect); + } + + /** + * @dev Update a Delay object so that it takes a new duration after at a timepoint that is automatically computed + * to enforce the old delay at the moment of the update. + */ + function update(Delay self, uint32 newValue, uint32 minSetback) internal view returns (Delay) { + uint32 value = self.get(); + uint32 setback = uint32(Math.max(minSetback, value > newValue ? value - newValue : 0)); + return self.updateAt(newValue, timestamp() + setback); + } + + /** + * @dev Split a delay into its components: oldValue, newValue and effect (transition timepoint). + */ + function split(Delay self) internal pure returns (uint32, uint32, uint48) { + uint112 raw = Delay.unwrap(self); + return ( + uint32(raw), // oldValue + uint32(raw >> 32), // newValue + uint48(raw >> 64) // effect + ); + } + + /** + * @dev pack the components into a Delay object. + */ + function pack(uint32 oldValue, uint32 newValue, uint48 effect) internal pure returns (Delay) { + return Delay.wrap(uint112(oldValue) | (uint112(newValue) << 32) | (uint112(effect) << 64)); + } +} diff --git a/package-lock.json b/package-lock.json index 22fe93cb7..60fa391df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "solhint": "^3.3.6", "solhint-plugin-openzeppelin": "file:scripts/solhint-custom", "solidity-ast": "^0.4.25", - "solidity-coverage": "^0.8.0", + "solidity-coverage": "^0.8.4", "solidity-docgen": "^0.6.0-beta.29", "undici": "^5.22.1", "web3": "^1.3.0", @@ -12796,9 +12796,9 @@ } }, "node_modules/solidity-coverage/node_modules/@solidity-parser/parser": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.0.tgz", - "integrity": "sha512-ESipEcHyRHg4Np4SqBCfcXwyxxna1DgFVz69bgpLV8vzl/NP1DtcKsJ4dJZXWQhY/Z4J2LeKBiOkOVZn9ct33Q==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.1.tgz", + "integrity": "sha512-PdhRFNhbTtu3x8Axm0uYpqOy/lODYQK+MlYSgqIsq2L8SFYEHJPHNUiOTAJbDGzNjjr1/n9AcIayxafR/fWmYw==", "dev": true, "dependencies": { "antlr4ts": "^0.5.0-alpha.4" @@ -25272,9 +25272,9 @@ }, "dependencies": { "@solidity-parser/parser": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.0.tgz", - "integrity": "sha512-ESipEcHyRHg4Np4SqBCfcXwyxxna1DgFVz69bgpLV8vzl/NP1DtcKsJ4dJZXWQhY/Z4J2LeKBiOkOVZn9ct33Q==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.1.tgz", + "integrity": "sha512-PdhRFNhbTtu3x8Axm0uYpqOy/lODYQK+MlYSgqIsq2L8SFYEHJPHNUiOTAJbDGzNjjr1/n9AcIayxafR/fWmYw==", "dev": true, "requires": { "antlr4ts": "^0.5.0-alpha.4" diff --git a/test/access/manager/AccessManager.test.js b/test/access/manager/AccessManager.test.js new file mode 100644 index 000000000..caa4ed293 --- /dev/null +++ b/test/access/manager/AccessManager.test.js @@ -0,0 +1,927 @@ +const { expectEvent, constants, time } = require('@openzeppelin/test-helpers'); +const { expectRevertCustomError } = require('../../helpers/customError'); +const { AccessMode } = require('../../helpers/enums'); +const { selector } = require('../../helpers/methods'); +const { clockFromReceipt } = require('../../helpers/time'); + +const AccessManager = artifacts.require('$AccessManager'); +const AccessManagedTarget = artifacts.require('$AccessManagedTarget'); + +const GROUPS = { + ADMIN: web3.utils.toBN(0), + SOME_ADMIN: web3.utils.toBN(17), + SOME: web3.utils.toBN(42), + PUBLIC: constants.MAX_UINT256, +}; +Object.assign(GROUPS, Object.fromEntries(Object.entries(GROUPS).map(([key, value]) => [value, key]))); + +const executeDelay = web3.utils.toBN(10); +const grantDelay = web3.utils.toBN(10); + +const MAX_UINT = n => web3.utils.toBN(1).shln(n).subn(1); + +const split = delay => ({ + oldValue: web3.utils.toBN(delay).shrn(0).and(MAX_UINT(32)).toString(), + newValue: web3.utils.toBN(delay).shrn(32).and(MAX_UINT(32)).toString(), + effect: web3.utils.toBN(delay).shrn(64).and(MAX_UINT(48)).toString(), +}); + +contract('AccessManager', function (accounts) { + const [admin, manager, member, user, other] = accounts; + + beforeEach(async function () { + this.manager = await AccessManager.new(admin); + this.target = await AccessManagedTarget.new(this.manager.address); + + // add member to group + await this.manager.$_setGroupAdmin(GROUPS.SOME, GROUPS.SOME_ADMIN); + await this.manager.$_setGroupGuardian(GROUPS.SOME, GROUPS.SOME_ADMIN); + await this.manager.$_grantGroup(GROUPS.SOME_ADMIN, manager, 0, 0); + await this.manager.$_grantGroup(GROUPS.SOME, member, 0, 0); + + // helpers for indirect calls + this.call = [this.target.address, selector('fnRestricted()')]; + this.opId = web3.utils.keccak256( + web3.eth.abi.encodeParameters(['address', 'address', 'bytes'], [user, ...this.call]), + ); + this.schedule = (opts = {}) => this.manager.schedule(...this.call, { from: user, ...opts }); + this.relay = (opts = {}) => this.manager.relay(...this.call, { from: user, ...opts }); + this.cancel = (opts = {}) => this.manager.cancel(user, ...this.call, { from: user, ...opts }); + }); + + it('groups are correctly initialized', async function () { + // group admin + expect(await this.manager.getGroupAdmin(GROUPS.ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN); + expect(await this.manager.getGroupAdmin(GROUPS.SOME_ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN); + expect(await this.manager.getGroupAdmin(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN); + expect(await this.manager.getGroupAdmin(GROUPS.PUBLIC)).to.be.bignumber.equal(GROUPS.ADMIN); + // group guardian + expect(await this.manager.getGroupGuardian(GROUPS.ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN); + expect(await this.manager.getGroupGuardian(GROUPS.SOME_ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN); + expect(await this.manager.getGroupGuardian(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN); + expect(await this.manager.getGroupGuardian(GROUPS.PUBLIC)).to.be.bignumber.equal(GROUPS.ADMIN); + // group members + expect(await this.manager.hasGroup(GROUPS.ADMIN, admin)).to.be.equal(true); + expect(await this.manager.hasGroup(GROUPS.ADMIN, manager)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.ADMIN, member)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.ADMIN, user)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, admin)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, manager)).to.be.equal(true); + expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, member)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, user)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.SOME, admin)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.SOME, manager)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.equal(true); + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.equal(false); + expect(await this.manager.hasGroup(GROUPS.PUBLIC, admin)).to.be.equal(true); + expect(await this.manager.hasGroup(GROUPS.PUBLIC, manager)).to.be.equal(true); + expect(await this.manager.hasGroup(GROUPS.PUBLIC, member)).to.be.equal(true); + expect(await this.manager.hasGroup(GROUPS.PUBLIC, user)).to.be.equal(true); + }); + + describe('Groups management', function () { + describe('label group', function () { + it('admin can emit a label event', async function () { + expectEvent(await this.manager.labelGroup(GROUPS.SOME, 'Some label', { from: admin }), 'GroupLabel', { + groupId: GROUPS.SOME, + label: 'Some label', + }); + }); + + it('admin can re-emit a label event', async function () { + await this.manager.labelGroup(GROUPS.SOME, 'Some label', { from: admin }); + + expectEvent(await this.manager.labelGroup(GROUPS.SOME, 'Updated label', { from: admin }), 'GroupLabel', { + groupId: GROUPS.SOME, + label: 'Updated label', + }); + }); + + it('emitting a label is restricted', async function () { + await expectRevertCustomError( + this.manager.labelGroup(GROUPS.SOME, 'Invalid label', { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.ADMIN], + ); + }); + }); + + describe('grand group', function () { + describe('without a grant delay', function () { + it('without an execute delay', async function () { + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + expectEvent(receipt, 'GroupGranted', { groupId: GROUPS.SOME, account: user, since: timestamp, delay: '0' }); + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.true; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal(timestamp); + }); + + it('with an execute delay', async function () { + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, executeDelay, { from: manager }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + expectEvent(receipt, 'GroupGranted', { + groupId: GROUPS.SOME, + account: user, + since: timestamp, + delay: executeDelay, + }); + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.true; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal(executeDelay); + expect(since).to.be.bignumber.equal(timestamp); + }); + + it('to a user that is already in the group', async function () { + expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.true; + + await expectRevertCustomError( + this.manager.grantGroup(GROUPS.SOME, member, 0, { from: manager }), + 'AccessManagerAcountAlreadyInGroup', + [GROUPS.SOME, member], + ); + }); + + it('to a user that is scheduled for joining the group', async function () { + await this.manager.$_grantGroup(GROUPS.SOME, user, 10, 0); // grant delay 10 + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + await expectRevertCustomError( + this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager }), + 'AccessManagerAcountAlreadyInGroup', + [GROUPS.SOME, user], + ); + }); + + it('grant group is restricted', async function () { + await expectRevertCustomError( + this.manager.grantGroup(GROUPS.SOME, user, 0, { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.SOME_ADMIN], + ); + }); + }); + + describe('with a grant delay', function () { + beforeEach(async function () { + await this.manager.$_setGrantDelay(GROUPS.SOME, grantDelay); + }); + + it('granted group is not active immediatly', async function () { + const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + expectEvent(receipt, 'GroupGranted', { + groupId: GROUPS.SOME, + account: user, + since: timestamp.add(grantDelay), + delay: '0', + }); + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal(timestamp.add(grantDelay)); + }); + + it('granted group is active after the delay', async function () { + const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + expectEvent(receipt, 'GroupGranted', { + groupId: GROUPS.SOME, + account: user, + since: timestamp.add(grantDelay), + delay: '0', + }); + + await time.increase(grantDelay); + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.true; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal(timestamp.add(grantDelay)); + }); + }); + }); + + describe('revoke group', function () { + it('from a user that is already in the group', async function () { + expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.true; + + const { receipt } = await this.manager.revokeGroup(GROUPS.SOME, member, { from: manager }); + expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: member }); + + expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.false; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal('0'); + }); + + it('from a user that is scheduled for joining the group', async function () { + await this.manager.$_grantGroup(GROUPS.SOME, user, 10, 0); // grant delay 10 + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { receipt } = await this.manager.revokeGroup(GROUPS.SOME, user, { from: manager }); + expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: user }); + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal('0'); + }); + + it('from a user that is not in the group', async function () { + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + await expectRevertCustomError( + this.manager.revokeGroup(GROUPS.SOME, user, { from: manager }), + 'AccessManagerAcountNotInGroup', + [GROUPS.SOME, user], + ); + }); + + it('revoke group is restricted', async function () { + await expectRevertCustomError( + this.manager.revokeGroup(GROUPS.SOME, member, { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.SOME_ADMIN], + ); + }); + }); + + describe('renounce group', function () { + it('for a user that is already in the group', async function () { + expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.true; + + const { receipt } = await this.manager.renounceGroup(GROUPS.SOME, member, { from: member }); + expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: member }); + + expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.false; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, member); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal('0'); + }); + + it('for a user that is schedule for joining the group', async function () { + await this.manager.$_grantGroup(GROUPS.SOME, user, 10, 0); // grant delay 10 + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { receipt } = await this.manager.renounceGroup(GROUPS.SOME, user, { from: user }); + expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: user }); + + expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false; + + const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user); + expect(delay).to.be.bignumber.equal('0'); + expect(since).to.be.bignumber.equal('0'); + }); + + it('for a user that is not in the group', async function () { + await expectRevertCustomError( + this.manager.renounceGroup(GROUPS.SOME, user, { from: user }), + 'AccessManagerAcountNotInGroup', + [GROUPS.SOME, user], + ); + }); + + it('bad user confirmation', async function () { + await expectRevertCustomError( + this.manager.renounceGroup(GROUPS.SOME, member, { from: user }), + 'AccessManagerBadConfirmation', + [], + ); + }); + }); + + describe('change group admin', function () { + it("admin can set any group's admin", async function () { + expect(await this.manager.getGroupAdmin(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN); + + const { receipt } = await this.manager.setGroupAdmin(GROUPS.SOME, GROUPS.ADMIN, { from: admin }); + expectEvent(receipt, 'GroupAdminChanged', { groupId: GROUPS.SOME, admin: GROUPS.ADMIN }); + + expect(await this.manager.getGroupAdmin(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.ADMIN); + }); + + it("seeting a group's admin is restricted", async function () { + await expectRevertCustomError( + this.manager.setGroupAdmin(GROUPS.SOME, GROUPS.SOME, { from: manager }), + 'AccessControlUnauthorizedAccount', + [manager, GROUPS.ADMIN], + ); + }); + }); + + describe('change group guardian', function () { + it("admin can set any group's admin", async function () { + expect(await this.manager.getGroupGuardian(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN); + + const { receipt } = await this.manager.setGroupGuardian(GROUPS.SOME, GROUPS.ADMIN, { from: admin }); + expectEvent(receipt, 'GroupGuardianChanged', { groupId: GROUPS.SOME, guardian: GROUPS.ADMIN }); + + expect(await this.manager.getGroupGuardian(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.ADMIN); + }); + + it("setting a group's admin is restricted", async function () { + await expectRevertCustomError( + this.manager.setGroupGuardian(GROUPS.SOME, GROUPS.SOME, { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.ADMIN], + ); + }); + }); + + describe('change execution delay', function () { + it('increassing the delay has immediate effect', async function () { + const oldDelay = web3.utils.toBN(10); + const newDelay = web3.utils.toBN(100); + + const { receipt: receipt1 } = await this.manager.$_setExecuteDelay(GROUPS.SOME, member, oldDelay); + const timestamp1 = await clockFromReceipt.timestamp(receipt1).then(web3.utils.toBN); + + const delayBefore = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay)); + expect(delayBefore.oldValue).to.be.bignumber.equal('0'); + expect(delayBefore.newValue).to.be.bignumber.equal(oldDelay); + expect(delayBefore.effect).to.be.bignumber.equal(timestamp1); + + const { receipt: receipt2 } = await this.manager.setExecuteDelay(GROUPS.SOME, member, newDelay, { + from: manager, + }); + const timestamp2 = await clockFromReceipt.timestamp(receipt2).then(web3.utils.toBN); + + expectEvent(receipt2, 'GroupExecutionDelayUpdate', { + groupId: GROUPS.SOME, + account: member, + delay: newDelay, + from: timestamp2, + }); + + // immediate effect + const delayAfter = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay)); + expect(delayAfter.oldValue).to.be.bignumber.equal(oldDelay); + expect(delayAfter.newValue).to.be.bignumber.equal(newDelay); + expect(delayAfter.effect).to.be.bignumber.equal(timestamp2); + }); + + it('decreassing the delay takes time', async function () { + const oldDelay = web3.utils.toBN(100); + const newDelay = web3.utils.toBN(10); + + const { receipt: receipt1 } = await this.manager.$_setExecuteDelay(GROUPS.SOME, member, oldDelay); + const timestamp1 = await clockFromReceipt.timestamp(receipt1).then(web3.utils.toBN); + + const delayBefore = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay)); + expect(delayBefore.oldValue).to.be.bignumber.equal('0'); + expect(delayBefore.newValue).to.be.bignumber.equal(oldDelay); + expect(delayBefore.effect).to.be.bignumber.equal(timestamp1); + + const { receipt: receipt2 } = await this.manager.setExecuteDelay(GROUPS.SOME, member, newDelay, { + from: manager, + }); + const timestamp2 = await clockFromReceipt.timestamp(receipt2).then(web3.utils.toBN); + + expectEvent(receipt2, 'GroupExecutionDelayUpdate', { + groupId: GROUPS.SOME, + account: member, + delay: newDelay, + from: timestamp2.add(oldDelay).sub(newDelay), + }); + + // delayed effect + const delayAfter = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay)); + + expect(delayAfter.oldValue).to.be.bignumber.equal(oldDelay); + expect(delayAfter.newValue).to.be.bignumber.equal(newDelay); + expect(delayAfter.effect).to.be.bignumber.equal(timestamp2.add(oldDelay).sub(newDelay)); + }); + + it('cannot set the delay of a non member', async function () { + await expectRevertCustomError( + this.manager.setExecuteDelay(GROUPS.SOME, other, executeDelay, { from: manager }), + 'AccessManagerAcountNotInGroup', + [GROUPS.SOME, other], + ); + }); + + it('can set a user execution delay during the grant delay', async function () { + await this.manager.$_grantGroup(GROUPS.SOME, other, 10, 0); + + const { receipt } = await this.manager.setExecuteDelay(GROUPS.SOME, other, executeDelay, { from: manager }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + + expectEvent(receipt, 'GroupExecutionDelayUpdate', { + groupId: GROUPS.SOME, + account: other, + delay: executeDelay, + from: timestamp, + }); + }); + + it('changing the execution delay is restricted', async function () { + await expectRevertCustomError( + this.manager.setExecuteDelay(GROUPS.SOME, member, executeDelay, { from: other }), + 'AccessControlUnauthorizedAccount', + [GROUPS.SOME_ADMIN, other], + ); + }); + }); + + describe('change grant delay', function () { + it('increassing the delay has immediate effect', async function () { + const oldDelay = web3.utils.toBN(10); + const newDelay = web3.utils.toBN(100); + await this.manager.$_setGrantDelay(GROUPS.SOME, oldDelay); + + expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(oldDelay); + + const { receipt } = await this.manager.setGrantDelay(GROUPS.SOME, newDelay, { from: admin }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + + expectEvent(receipt, 'GroupGrantDelayChanged', { groupId: GROUPS.SOME, delay: newDelay, from: timestamp }); + + expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(newDelay); + }); + + it('increassing the delay has delay effect', async function () { + const oldDelay = web3.utils.toBN(100); + const newDelay = web3.utils.toBN(10); + await this.manager.$_setGrantDelay(GROUPS.SOME, oldDelay); + + expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(oldDelay); + + const { receipt } = await this.manager.setGrantDelay(GROUPS.SOME, newDelay, { from: admin }); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + + expectEvent(receipt, 'GroupGrantDelayChanged', { + groupId: GROUPS.SOME, + delay: newDelay, + from: timestamp.add(oldDelay).sub(newDelay), + }); + + expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(oldDelay); + + await time.increase(oldDelay.sub(newDelay)); + + expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(newDelay); + }); + + it('changing the grant delay is restricted', async function () { + await expectRevertCustomError( + this.manager.setGrantDelay(GROUPS.SOME, grantDelay, { from: other }), + 'AccessControlUnauthorizedAccount', + [GROUPS.ADMIN, other], + ); + }); + }); + }); + + describe('Mode management', function () { + for (const [modeName, mode] of Object.entries(AccessMode)) { + describe(`setContractMode${modeName}`, function () { + it('set the mode and emits an event', async function () { + // set the target to another mode, so we can check the effects + await this.manager.$_setContractMode( + this.target.address, + Object.values(AccessMode).find(m => m != mode), + ); + + expect(await this.manager.getContractMode(this.target.address)).to.not.be.bignumber.equal(mode); + + expectEvent( + await this.manager[`setContractMode${modeName}`](this.target.address, { from: admin }), + 'AccessModeUpdated', + { target: this.target.address, mode }, + ); + + expect(await this.manager.getContractMode(this.target.address)).to.be.bignumber.equal(mode); + }); + + it('is restricted', async function () { + await expectRevertCustomError( + this.manager[`setContractMode${modeName}`](this.target.address, { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.ADMIN], + ); + }); + }); + } + }); + + describe('Change function permissions', function () { + const sigs = ['someFunction()', 'someOtherFunction(uint256)', 'oneMoreFunction(address,uint8)'].map(selector); + + it('admin can set function allowed group', async function () { + for (const sig of sigs) { + expect(await this.manager.getFunctionAllowedGroup(this.target.address, sig)).to.be.bignumber.equal( + GROUPS.ADMIN, + ); + } + + const { receipt: receipt1 } = await this.manager.setFunctionAllowedGroup(this.target.address, sigs, GROUPS.SOME, { + from: admin, + }); + + for (const sig of sigs) { + expectEvent(receipt1, 'FunctionAllowedGroupUpdated', { + target: this.target.address, + selector: sig, + groupId: GROUPS.SOME, + }); + expect(await this.manager.getFunctionAllowedGroup(this.target.address, sig)).to.be.bignumber.equal(GROUPS.SOME); + } + + const { receipt: receipt2 } = await this.manager.setFunctionAllowedGroup( + this.target.address, + [sigs[1]], + GROUPS.SOME_ADMIN, + { from: admin }, + ); + expectEvent(receipt2, 'FunctionAllowedGroupUpdated', { + target: this.target.address, + selector: sigs[1], + groupId: GROUPS.SOME_ADMIN, + }); + + for (const sig of sigs) { + expect(await this.manager.getFunctionAllowedGroup(this.target.address, sig)).to.be.bignumber.equal( + sig == sigs[1] ? GROUPS.SOME_ADMIN : GROUPS.SOME, + ); + } + }); + + it('changing function permissions is restricted', async function () { + await expectRevertCustomError( + this.manager.setFunctionAllowedGroup(this.target.address, sigs, GROUPS.SOME, { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.ADMIN], + ); + }); + }); + + describe('Calling restricted & unrestricted functions', function () { + const product = (...arrays) => arrays.reduce((a, b) => a.flatMap(ai => b.map(bi => [ai, bi].flat()))); + + for (const [callerOpt, targetOpt] of product( + [ + { groups: [] }, + { groups: [GROUPS.SOME] }, + { groups: [GROUPS.SOME], delay: executeDelay }, + { groups: [GROUPS.SOME, GROUPS.PUBLIC], delay: executeDelay }, + ], + [ + { mode: AccessMode.Open }, + { mode: AccessMode.Closed }, + { mode: AccessMode.Custom, group: GROUPS.ADMIN }, + { mode: AccessMode.Custom, group: GROUPS.SOME }, + { mode: AccessMode.Custom, group: GROUPS.PUBLIC }, + ], + )) { + const public = + targetOpt.mode == AccessMode.Open || (targetOpt.mode == AccessMode.Custom && targetOpt.group == GROUPS.PUBLIC); + + // can we call with a delay ? + const indirectSuccess = + public || (targetOpt.mode == AccessMode.Custom && callerOpt.groups?.includes(targetOpt.group)); + + // can we call without a delay ? + const directSuccess = + public || + (targetOpt.mode == AccessMode.Custom && callerOpt.groups?.includes(targetOpt.group) && !callerOpt.delay); + + const description = [ + 'Caller in groups', + '[' + (callerOpt.groups ?? []).map(groupId => GROUPS[groupId]).join(', ') + ']', + callerOpt.delay ? 'with a delay' : 'without a delay', + '+', + 'contract in mode', + Object.keys(AccessMode)[targetOpt.mode.toNumber()], + targetOpt.mode == AccessMode.Custom ? `(${GROUPS[targetOpt.group]})` : '', + ].join(' '); + + describe(description, function () { + beforeEach(async function () { + // setup + await Promise.all([ + this.manager.$_setContractMode(this.target.address, targetOpt.mode), + targetOpt.group && + this.manager.$_setFunctionAllowedGroup(this.target.address, selector('fnRestricted()'), targetOpt.group), + targetOpt.group && + this.manager.$_setFunctionAllowedGroup( + this.target.address, + selector('fnUnrestricted()'), + targetOpt.group, + ), + ...(callerOpt.groups ?? []) + .filter(groupId => groupId != GROUPS.PUBLIC) + .map(groupId => this.manager.$_grantGroup(groupId, user, 0, callerOpt.delay ?? 0)), + ]); + + // post setup checks + expect(await this.manager.getContractMode(this.target.address)).to.be.bignumber.equal(targetOpt.mode); + if (targetOpt.group) { + expect( + await this.manager.getFunctionAllowedGroup(this.target.address, selector('fnRestricted()')), + ).to.be.bignumber.equal(targetOpt.group); + expect( + await this.manager.getFunctionAllowedGroup(this.target.address, selector('fnUnrestricted()')), + ).to.be.bignumber.equal(targetOpt.group); + } + for (const groupId of callerOpt.groups ?? []) { + const access = await this.manager.getAccess(groupId, user); + if (groupId == GROUPS.PUBLIC) { + expect(access.since).to.be.bignumber.eq('0'); + expect(access.delay).to.be.bignumber.eq('0'); + } else { + expect(access.since).to.be.bignumber.gt('0'); + expect(access.delay).to.be.bignumber.eq(String(callerOpt.delay ?? 0)); + } + } + }); + + it('canCall', async function () { + const result = await this.manager.canCall(user, this.target.address, selector('fnRestricted()')); + expect(result[0]).to.be.equal(directSuccess); + expect(result[1]).to.be.bignumber.equal(!directSuccess && indirectSuccess ? callerOpt.delay ?? '0' : '0'); + }); + + it('Calling a non restricted function never revert', async function () { + expectEvent(await this.target.fnUnrestricted({ from: user }), 'CalledUnrestricted', { + caller: user, + }); + }); + + it(`Calling a restricted function directly should ${directSuccess ? 'succeed' : 'revert'}`, async function () { + const promise = this.target.fnRestricted({ from: user }); + + if (directSuccess) { + expectEvent(await promise, 'CalledRestricted', { caller: user }); + } else { + await expectRevertCustomError(promise, 'AccessManagedUnauthorized', [user]); + } + }); + + it('Calling indirectly: only relay', async function () { + // relay without schedule + if (directSuccess) { + const { receipt, tx } = await this.relay(); + expectEvent.notEmitted(receipt, 'Executed', { operationId: this.opId }); + expectEvent.inTransaction(tx, this.target, 'Calledrestricted', { caller: this.manager.address }); + } else if (indirectSuccess) { + await expectRevertCustomError(this.relay(), 'AccessManagerNotScheduled', [this.opId]); + } else { + await expectRevertCustomError(this.relay(), 'AccessManagerUnauthorizedCall', [user, ...this.call]); + } + }); + + it('Calling indirectly: schedule and relay', async function () { + if (directSuccess || indirectSuccess) { + const { receipt } = await this.schedule(); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + + expectEvent(receipt, 'Scheduled', { + operationId: this.opId, + caller: user, + target: this.call[0], + data: this.call[1], + }); + + // if can call directly, delay should be 0. Otherwize, the delay should be applied + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal( + timestamp.add(directSuccess ? web3.utils.toBN(0) : callerOpt.delay), + ); + + // execute without wait + if (directSuccess) { + const { receipt, tx } = await this.relay(); + expectEvent(receipt, 'Executed', { operationId: this.opId }); + expectEvent.inTransaction(tx, this.target, 'Calledrestricted', { caller: this.manager.address }); + + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0'); + } else if (indirectSuccess) { + await expectRevertCustomError(this.relay(), 'AccessManagerNotReady', [this.opId]); + } else { + await expectRevertCustomError(this.relay(), 'AccessManagerUnauthorizedCall', [user, ...this.call]); + } + } else { + await expectRevertCustomError(this.schedule(), 'AccessManagerUnauthorizedCall', [user, ...this.call]); + } + }); + + it('Calling indirectly: schedule wait and relay', async function () { + if (directSuccess || indirectSuccess) { + const { receipt } = await this.schedule(); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + + expectEvent(receipt, 'Scheduled', { + operationId: this.opId, + caller: user, + target: this.call[0], + data: this.call[1], + }); + + // if can call directly, delay should be 0. Otherwize, the delay should be applied + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal( + timestamp.add(directSuccess ? web3.utils.toBN(0) : callerOpt.delay), + ); + + // wait + await time.increase(callerOpt.delay ?? 0); + + // execute without wait + if (directSuccess || indirectSuccess) { + const { receipt, tx } = await this.relay(); + expectEvent(receipt, 'Executed', { operationId: this.opId }); + expectEvent.inTransaction(tx, this.target, 'Calledrestricted', { caller: this.manager.address }); + + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0'); + } else { + await expectRevertCustomError(this.relay(), 'AccessManagerUnauthorizedCall', [user, ...this.call]); + } + } else { + await expectRevertCustomError(this.schedule(), 'AccessManagerUnauthorizedCall', [user, ...this.call]); + } + }); + }); + } + }); + + describe('Indirect execution corner-cases', async function () { + beforeEach(async function () { + await this.manager.$_setFunctionAllowedGroup(...this.call, GROUPS.SOME); + await this.manager.$_grantGroup(GROUPS.SOME, user, 0, executeDelay); + }); + + it('Checking canCall when caller is the manager depend on the _relayIdentifier', async function () { + expect(await this.manager.getContractMode(this.target.address)).to.be.bignumber.equal(AccessMode.Custom); + + const result = await this.manager.canCall(this.manager.address, this.target.address, '0x00000000'); + expect(result[0]).to.be.false; + expect(result[1]).to.be.bignumber.equal('0'); + }); + + it('Cannot execute earlier', async function () { + const { receipt } = await this.schedule(); + const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN); + + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal(timestamp.add(executeDelay)); + + // we need to set the clock 2 seconds before the value, because the increaseTo "consumes" the timestamp + // and the next transaction will be one after that (see check bellow) + await time.increaseTo(timestamp.add(executeDelay).subn(2)); + + // too early + await expectRevertCustomError(this.relay(), 'AccessManagerNotReady', [this.opId]); + + // the revert happened one second before the execution delay expired + expect(await time.latest()).to.be.bignumber.equal(timestamp.add(executeDelay).subn(1)); + + // ok + await this.relay(); + + // the success happened when the delay was reached (earliest possible) + expect(await time.latest()).to.be.bignumber.equal(timestamp.add(executeDelay)); + }); + + it('Cannot schedule an already scheduled operation', async function () { + const { receipt } = await this.schedule(); + expectEvent(receipt, 'Scheduled', { + operationId: this.opId, + caller: user, + target: this.call[0], + data: this.call[1], + }); + + await expectRevertCustomError(this.schedule(), 'AccessManagerAlreadyScheduled', [this.opId]); + }); + + it('Cannot cancel an operation that is not scheduled', async function () { + await expectRevertCustomError(this.cancel(), 'AccessManagerNotScheduled', [this.opId]); + }); + + it('Cannot cancel an operation that is not already relayed', async function () { + await this.schedule(); + await time.increase(executeDelay); + await this.relay(); + + await expectRevertCustomError(this.cancel(), 'AccessManagerNotScheduled', [this.opId]); + }); + + it('Scheduler can cancel', async function () { + await this.schedule(); + + expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0'); + + expectEvent(await this.cancel({ from: manager }), 'Canceled', { operationId: this.opId }); + + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0'); + }); + + it('Guardian can cancel', async function () { + await this.schedule(); + + expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0'); + + expectEvent(await this.cancel({ from: manager }), 'Canceled', { operationId: this.opId }); + + expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0'); + }); + + it('Cancel is restricted', async function () { + await this.schedule(); + + expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0'); + + await expectRevertCustomError(this.cancel({ from: other }), 'AccessManagerCannotCancel', [ + other, + user, + ...this.call, + ]); + + expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0'); + }); + + it('Can re-schedule after execution', async function () { + await this.schedule(); + await time.increase(executeDelay); + await this.relay(); + + // reschedule + const { receipt } = await this.schedule(); + expectEvent(receipt, 'Scheduled', { + operationId: this.opId, + caller: user, + target: this.call[0], + data: this.call[1], + }); + }); + + it('Can re-schedule after cancel', async function () { + await this.schedule(); + await this.cancel(); + + // reschedule + const { receipt } = await this.schedule(); + expectEvent(receipt, 'Scheduled', { + operationId: this.opId, + caller: user, + target: this.call[0], + data: this.call[1], + }); + }); + }); + + describe('authority update', function () { + beforeEach(async function () { + this.newManager = await AccessManager.new(admin); + }); + + it('admin can change authority', async function () { + expect(await this.target.authority()).to.be.equal(this.manager.address); + + const { tx } = await this.manager.updateAuthority(this.target.address, this.newManager.address, { from: admin }); + expectEvent.inTransaction(tx, this.target, 'AuthorityUpdated', { authority: this.newManager.address }); + + expect(await this.target.authority()).to.be.equal(this.newManager.address); + }); + + it('cannot set an address without code as the authority', async function () { + await expectRevertCustomError( + this.manager.updateAuthority(this.target.address, user, { from: admin }), + 'AccessManagedInvalidAuthority', + [user], + ); + }); + + it('updateAuthority is restricted on manager', async function () { + await expectRevertCustomError( + this.manager.updateAuthority(this.target.address, this.newManager.address, { from: other }), + 'AccessControlUnauthorizedAccount', + [other, GROUPS.ADMIN], + ); + }); + + it('setAuthority is restricted on AccessManaged', async function () { + await expectRevertCustomError( + this.target.setAuthority(this.newManager.address, { from: admin }), + 'AccessManagedUnauthorized', + [admin], + ); + }); + }); +}); diff --git a/test/access/manager/utils/AccessManagedAdapter.test.js b/test/access/manager/utils/AccessManagedAdapter.test.js new file mode 100644 index 000000000..612442e30 --- /dev/null +++ b/test/access/manager/utils/AccessManagedAdapter.test.js @@ -0,0 +1,158 @@ +const { constants, time } = require('@openzeppelin/test-helpers'); +const { expectRevertCustomError } = require('../../../helpers/customError'); +const { AccessMode } = require('../../../helpers/enums'); +const { selector } = require('../../../helpers/methods'); + +const AccessManager = artifacts.require('$AccessManager'); +const AccessManagedAdapter = artifacts.require('AccessManagedAdapter'); +const Ownable = artifacts.require('$Ownable'); + +const groupId = web3.utils.toBN(1); + +contract('AccessManagedAdapter', function (accounts) { + const [admin, user, other] = accounts; + + beforeEach(async function () { + this.manager = await AccessManager.new(admin); + this.adapter = await AccessManagedAdapter.new(this.manager.address); + this.ownable = await Ownable.new(this.adapter.address); + + // add user to group + await this.manager.$_grantGroup(groupId, user, 0, 0); + }); + + it('initial state', async function () { + expect(await this.adapter.authority()).to.be.equal(this.manager.address); + expect(await this.ownable.owner()).to.be.equal(this.adapter.address); + }); + + describe('Contract is Closed', function () { + beforeEach(async function () { + await this.manager.$_setContractMode(this.ownable.address, AccessMode.Closed); + }); + + it('directly call: reverts', async function () { + await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]); + }); + + it('relayed call (with group): reverts', async function () { + await expectRevertCustomError( + this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }), + 'AccessManagedUnauthorized', + [user], + ); + }); + + it('relayed call (without group): reverts', async function () { + await expectRevertCustomError( + this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other }), + 'AccessManagedUnauthorized', + [other], + ); + }); + }); + + describe('Contract is Open', function () { + beforeEach(async function () { + await this.manager.$_setContractMode(this.ownable.address, AccessMode.Open); + }); + + it('directly call: reverts', async function () { + await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]); + }); + + it('relayed call (with group): success', async function () { + await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }); + }); + + it('relayed call (without group): success', async function () { + await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other }); + }); + }); + + describe('Contract is in Custom mode', function () { + beforeEach(async function () { + await this.manager.$_setContractMode(this.ownable.address, AccessMode.Custom); + }); + + describe('function is open to specific group', function () { + beforeEach(async function () { + await this.manager.$_setFunctionAllowedGroup(this.ownable.address, selector('$_checkOwner()'), groupId); + }); + + it('directly call: reverts', async function () { + await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]); + }); + + it('relayed call (with group): success', async function () { + await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }); + }); + + it('relayed call (without group): reverts', async function () { + await expectRevertCustomError( + this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other }), + 'AccessManagedUnauthorized', + [other], + ); + }); + }); + + describe('function is open to public group', function () { + beforeEach(async function () { + await this.manager.$_setFunctionAllowedGroup( + this.ownable.address, + selector('$_checkOwner()'), + constants.MAX_UINT256, + ); + }); + + it('directly call: reverts', async function () { + await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]); + }); + + it('relayed call (with group): success', async function () { + await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }); + }); + + it('relayed call (without group): success', async function () { + await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other }); + }); + }); + + describe('function is available with execution delay', function () { + const delay = 10; + + beforeEach(async function () { + await this.manager.$_setExecuteDelay(groupId, user, delay); + await this.manager.$_setFunctionAllowedGroup(this.ownable.address, selector('$_checkOwner()'), groupId); + }); + + it('unscheduled call reverts', async function () { + await expectRevertCustomError( + this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }), + 'AccessManagedRequiredDelay', + [user, delay], + ); + }); + + it('scheduled call succeeds', async function () { + await this.manager.schedule(this.ownable.address, selector('$_checkOwner()'), { from: user }); + await time.increase(delay); + await this.manager.relayViaAdapter(this.ownable.address, selector('$_checkOwner()'), this.adapter.address, { + from: user, + }); + }); + }); + }); + + it('bubble revert reasons', async function () { + const { address } = await Ownable.new(admin); + await this.manager.$_setContractMode(address, AccessMode.Open); + + await expectRevertCustomError( + this.adapter.relay(address, selector('$_checkOwner()')), + 'OwnableUnauthorizedAccount', + [this.adapter.address], + ); + }); +}); diff --git a/test/helpers/enums.js b/test/helpers/enums.js index 6280e0f31..fd90ec4f1 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -8,4 +8,5 @@ module.exports = { VoteType: Enum('Against', 'For', 'Abstain'), Rounding: Enum('Floor', 'Ceil', 'Trunc', 'Expand'), OperationState: Enum('Unset', 'Waiting', 'Ready', 'Done'), + AccessMode: Enum('Custom', 'Closed', 'Open'), }; diff --git a/test/helpers/methods.js b/test/helpers/methods.js new file mode 100644 index 000000000..cb30d8727 --- /dev/null +++ b/test/helpers/methods.js @@ -0,0 +1,5 @@ +const { soliditySha3 } = require('web3-utils'); + +module.exports = { + selector: signature => soliditySha3(signature).substring(0, 10), +};