Pārlūkot izejas kodu

Add AccessManager contracts (#4121)

Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
Co-authored-by: Ernesto García <ernestognw@gmail.com>
Francisco 2 gadi atpakaļ
vecāks
revīzija
fa112be682

+ 5 - 0
.changeset/quiet-trainers-kick.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`AccessManager`: Added a new contract for managing access control of complex systems in a consolidated location.

+ 10 - 0
contracts/access/README.adoc

@@ -25,3 +25,13 @@ This directory provides ways to restrict who can access the functions of a contr
 {{AccessControlEnumerable}}
 
 {{AccessControlDefaultAdminRules}}
+
+== AccessManager
+
+{{IAuthority}}
+
+{{AccessManager}}
+
+{{AccessManaged}}
+
+{{AccessManagerAdapter}}

+ 75 - 0
contracts/access/manager/AccessManaged.sol

@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../../utils/Context.sol";
+import "./IAuthority.sol";
+
+/**
+ * @dev This contract module makes available a {restricted} modifier. Functions decorated with this modifier will be
+ * permissioned according to an "authority": a contract like {AccessManager} that follows the {IAuthority} interface,
+ * implementing a policy that allows certain callers access to certain functions.
+ *
+ * IMPORTANT: The `restricted` modifier should never be used on `internal` functions, judiciously used in `public`
+ * functions, and ideally only used in `external` functions. See {restricted}.
+ */
+contract AccessManaged is Context {
+    event AuthorityUpdated(address indexed sender, IAuthority indexed newAuthority);
+
+    IAuthority private _authority;
+
+    /**
+     * @dev Restricts access to a function as defined by the connected Authority for this contract and the
+     * caller and selector of the function that entered the contract.
+     *
+     * [IMPORTANT]
+     * ====
+     * In general, this modifier should only be used on `external` functions. It is okay to use it on `public` functions
+     * that are used as external entry points and are not called internally. Unless you know what you're doing, it
+     * should never be used on `internal` functions. Failure to follow these rules can have critical security
+     * implications! This is because the permissions are determined by the function that entered the contract, i.e. the
+     * function at the bottom of the call stack, and not the function where the modifier is visible in the source code.
+     * ====
+     */
+    modifier restricted() {
+        _checkCanCall(_msgSender(), msg.sig);
+        _;
+    }
+
+    /**
+     * @dev Initializes the contract connected to an initial authority.
+     */
+    constructor(IAuthority initialAuthority) {
+        _setAuthority(initialAuthority);
+    }
+
+    /**
+     * @dev Returns the current authority.
+     */
+    function authority() public view virtual returns (IAuthority) {
+        return _authority;
+    }
+
+    /**
+     * @dev Transfers control to a new authority. The caller must be the current authority.
+     */
+    function setAuthority(IAuthority newAuthority) public virtual {
+        require(_msgSender() == address(_authority), "AccessManaged: not current authority");
+        _setAuthority(newAuthority);
+    }
+
+    /**
+     * @dev Transfers control to a new authority. Internal function with no access restriction.
+     */
+    function _setAuthority(IAuthority newAuthority) internal virtual {
+        _authority = newAuthority;
+        emit AuthorityUpdated(_msgSender(), newAuthority);
+    }
+
+    /**
+     * @dev Reverts if the caller is not allowed to call the function identified by a selector.
+     */
+    function _checkCanCall(address caller, bytes4 selector) internal view virtual {
+        require(_authority.canCall(caller, address(this), selector), "AccessManaged: authority rejected");
+    }
+}

+ 341 - 0
contracts/access/manager/AccessManager.sol

@@ -0,0 +1,341 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.13;
+
+import "../AccessControl.sol";
+import "../AccessControlDefaultAdminRules.sol";
+import "./IAuthority.sol";
+import "./AccessManaged.sol";
+
+interface IAccessManager is IAuthority, IAccessControlDefaultAdminRules {
+    enum AccessMode {
+        Custom,
+        Closed,
+        Open
+    }
+
+    event GroupUpdated(uint8 indexed group, string name);
+
+    event GroupAllowed(address indexed target, bytes4 indexed selector, uint8 indexed group, bool allowed);
+
+    event AccessModeUpdated(address indexed target, AccessMode indexed mode);
+
+    function createGroup(uint8 group, string calldata name) external;
+
+    function updateGroupName(uint8 group, string calldata name) external;
+
+    function hasGroup(uint8 group) external view returns (bool);
+
+    function getUserGroups(address user) external view returns (bytes32 groups);
+
+    function grantGroup(uint8 group, address user) external;
+
+    function revokeGroup(uint8 group, address user) external;
+
+    function renounceGroup(uint8 group, address user) external;
+
+    function getFunctionAllowedGroups(address target, bytes4 selector) external view returns (bytes32 groups);
+
+    function setFunctionAllowedGroup(address target, bytes4[] calldata selectors, uint8 group, bool allowed) external;
+
+    function getContractMode(address target) external view returns (AccessMode);
+
+    function setContractModeCustom(address target) external;
+
+    function setContractModeOpen(address target) external;
+
+    function setContractModeClosed(address target) external;
+
+    function transferContractAuthority(address target, address newAuthority) external;
+}
+
+/**
+ * @dev AccessManager is a central contract to store the permissions of a system.
+ *
+ * The smart contracts under the control of an AccessManager instance will have a set of "restricted" functions, and the
+ * exact details of how access is restricted for each of those functions is configurable by the admins of the instance.
+ * These restrictions are expressed in terms of "groups".
+ *
+ * An AccessManager instance will define a set of groups. Each of them must be created before they can be granted, with
+ * a maximum of 255 created groups. Users can be added into any number of these groups. Each of them defines an
+ * AccessControl role, and may confer access to some of the restricted functions in the system, as configured by admins
+ * through the use of {setFunctionAllowedGroup}.
+ *
+ * Note that a function in a target contract may become permissioned in this way only when: 1) said contract is
+ * {AccessManaged} and is connected to this contract as its manager, and 2) said function is decorated with the
+ * `restricted` modifier.
+ *
+ * There is a special group defined by default named "public" which all accounts automatically have.
+ *
+ * Contracts can also be configured in two special modes: 1) the "open" mode, where all functions are allowed to the
+ * "public" group, and 2) the "closed" mode, where no function is allowed to any group.
+ *
+ * Since all the permissions of the managed system can be modified by the admins of this instance, it is expected that
+ * it will be highly secured (e.g., a multisig or a well-configured DAO). Additionally, {AccessControlDefaultAdminRules}
+ * is included to enforce security rules on this account.
+ *
+ * NOTE: Some of the functions in this contract, such as {getUserGroups}, return a `bytes32` bitmap to succintly
+ * represent a set of groups. In a bitmap, bit `n` (counting from the least significant bit) will be 1 if and only if
+ * the group with number `n` is in the set. For example, the hex value `0x05` represents the set of the two groups
+ * numbered 0 and 2 from its binary equivalence `0b101`
+ */
+contract AccessManager is IAccessManager, AccessControlDefaultAdminRules {
+    bytes32 _createdGroups;
+
+    // user -> groups
+    mapping(address => bytes32) private _userGroups;
+
+    // target -> selector -> groups
+    mapping(address => mapping(bytes4 => bytes32)) private _allowedGroups;
+
+    // target -> mode
+    mapping(address => AccessMode) private _contractMode;
+
+    uint8 private constant _GROUP_PUBLIC = type(uint8).max;
+
+    /**
+     * @dev Initializes an AccessManager with initial default admin and transfer delay.
+     */
+    constructor(
+        uint48 initialDefaultAdminDelay,
+        address initialDefaultAdmin
+    ) AccessControlDefaultAdminRules(initialDefaultAdminDelay, initialDefaultAdmin) {
+        _createGroup(_GROUP_PUBLIC, "public");
+    }
+
+    /**
+     * @dev Returns true if the caller can invoke on a target the function identified by a function selector.
+     * Entrypoint for {AccessManaged} contracts.
+     */
+    function canCall(address caller, address target, bytes4 selector) public view virtual returns (bool) {
+        bytes32 allowedGroups = getFunctionAllowedGroups(target, selector);
+        bytes32 callerGroups = getUserGroups(caller);
+        return callerGroups & allowedGroups != 0;
+    }
+
+    /**
+     * @dev Creates a new group with a group number that can be chosen arbitrarily but must be unused, and gives it a
+     * human-readable name. The caller must be the default admin.
+     *
+     * Group numbers are not auto-incremented in order to avoid race conditions, but administrators can safely use
+     * sequential numbers.
+     *
+     * Emits {GroupUpdated}.
+     */
+    function createGroup(uint8 group, string memory name) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        _createGroup(group, name);
+    }
+
+    /**
+     * @dev Updates an existing group's name. The caller must be the default admin.
+     */
+    function updateGroupName(uint8 group, string memory name) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        require(group != _GROUP_PUBLIC, "AccessManager: built-in group");
+        require(hasGroup(group), "AccessManager: unknown group");
+        emit GroupUpdated(group, name);
+    }
+
+    /**
+     * @dev Returns true if the group has already been created via {createGroup}.
+     */
+    function hasGroup(uint8 group) public view virtual returns (bool) {
+        return _getGroup(_createdGroups, group);
+    }
+
+    /**
+     * @dev Returns a bitmap of the groups the user has. See note on bitmaps above.
+     */
+    function getUserGroups(address user) public view virtual returns (bytes32) {
+        return _userGroups[user] | _groupMask(_GROUP_PUBLIC);
+    }
+
+    /**
+     * @dev Grants a user a group.
+     *
+     * Emits {RoleGranted} with the role id of the group, if wasn't already held by the user.
+     */
+    function grantGroup(uint8 group, address user) public virtual {
+        grantRole(_encodeGroupRole(group), user); // will check msg.sender
+    }
+
+    /**
+     * @dev Removes a group from a user.
+     *
+     * Emits {RoleRevoked} with the role id of the group, if previously held by the user.
+     */
+    function revokeGroup(uint8 group, address user) public virtual {
+        revokeRole(_encodeGroupRole(group), user); // will check msg.sender
+    }
+
+    /**
+     * @dev Allows a user to renounce a group.
+     *
+     * Emits {RoleRevoked} with the role id of the group, if previously held by the user.
+     */
+    function renounceGroup(uint8 group, address user) public virtual {
+        renounceRole(_encodeGroupRole(group), user); // will check msg.sender
+    }
+
+    /**
+     * @dev Returns a bitmap of the groups that are allowed to call a function of a target contract. If the target
+     * contract is in open or closed mode it will be reflected in the return value.
+     */
+    function getFunctionAllowedGroups(address target, bytes4 selector) public view virtual returns (bytes32) {
+        AccessMode mode = getContractMode(target);
+        if (mode == AccessMode.Open) {
+            return _groupMask(_GROUP_PUBLIC);
+        } else if (mode == AccessMode.Closed) {
+            return 0;
+        } else {
+            return _allowedGroups[target][selector];
+        }
+    }
+
+    /**
+     * @dev Changes whether a group is allowed to call a function of a contract, according to the `allowed` argument.
+     * The caller must be the default admin.
+     */
+    function setFunctionAllowedGroup(
+        address target,
+        bytes4[] calldata selectors,
+        uint8 group,
+        bool allowed
+    ) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        for (uint256 i = 0; i < selectors.length; i++) {
+            bytes4 selector = selectors[i];
+            _allowedGroups[target][selector] = _withUpdatedGroup(_allowedGroups[target][selector], group, allowed);
+            emit GroupAllowed(target, selector, group, allowed);
+        }
+    }
+
+    /**
+     * @dev Returns the mode of the target contract, which may be custom (`0`), closed (`1`), or open (`2`).
+     */
+    function getContractMode(address target) public view virtual returns (AccessMode) {
+        return _contractMode[target];
+    }
+
+    /**
+     * @dev Sets the target contract to be in custom restricted mode. All restricted functions in the target contract
+     * will follow the group-based restrictions defined by the AccessManager. The caller must be the default admin.
+     */
+    function setContractModeCustom(address target) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        _setContractMode(target, AccessMode.Custom);
+    }
+
+    /**
+     * @dev Sets the target contract to be in "open" mode. All restricted functions in the target contract will become
+     * callable by anyone. The caller must be the default admin.
+     */
+    function setContractModeOpen(address target) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        _setContractMode(target, AccessMode.Open);
+    }
+
+    /**
+     * @dev Sets the target contract to be in "closed" mode. All restricted functions in the target contract will be
+     * closed down and disallowed to all. The caller must be the default admin.
+     */
+    function setContractModeClosed(address target) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        _setContractMode(target, AccessMode.Closed);
+    }
+
+    /**
+     * @dev Transfers a target contract onto a new authority. The caller must be the default admin.
+     */
+    function transferContractAuthority(
+        address target,
+        address newAuthority
+    ) public virtual onlyRole(DEFAULT_ADMIN_ROLE) {
+        AccessManaged(target).setAuthority(IAuthority(newAuthority));
+    }
+
+    /**
+     * @dev Creates a new group.
+     *
+     * Emits {GroupUpdated}.
+     */
+    function _createGroup(uint8 group, string memory name) internal virtual {
+        require(!hasGroup(group), "AccessManager: existing group");
+        _createdGroups = _withUpdatedGroup(_createdGroups, group, true);
+        emit GroupUpdated(group, name);
+    }
+
+    /**
+     * @dev Augmented version of {AccessControl-_grantRole} that keeps track of user group bitmaps.
+     */
+    function _grantRole(bytes32 role, address user) internal virtual override {
+        super._grantRole(role, user);
+        (bool isGroup, uint8 group) = _decodeGroupRole(role);
+        if (isGroup) {
+            require(hasGroup(group), "AccessManager: unknown group");
+            _userGroups[user] = _withUpdatedGroup(_userGroups[user], group, true);
+        }
+    }
+
+    /**
+     * @dev Augmented version of {AccessControl-_revokeRole} that keeps track of user group bitmaps.
+     */
+    function _revokeRole(bytes32 role, address user) internal virtual override {
+        super._revokeRole(role, user);
+        (bool isGroup, uint8 group) = _decodeGroupRole(role);
+        if (isGroup) {
+            require(hasGroup(group), "AccessManager: unknown group");
+            require(group != _GROUP_PUBLIC, "AccessManager: irrevocable group");
+            _userGroups[user] = _withUpdatedGroup(_userGroups[user], group, false);
+        }
+    }
+
+    /**
+     * @dev Sets the restricted mode of a target contract.
+     */
+    function _setContractMode(address target, AccessMode mode) internal virtual {
+        _contractMode[target] = mode;
+        emit AccessModeUpdated(target, mode);
+    }
+
+    /**
+     * @dev Returns the {AccessControl} role id that corresponds to a group.
+     *
+     * This role id starts with the ASCII characters `group:`, followed by zeroes, and ends with the single byte
+     * corresponding to the group number.
+     */
+    function _encodeGroupRole(uint8 group) internal pure virtual returns (bytes32) {
+        return bytes32("group:") | bytes32(uint256(group));
+    }
+
+    /**
+     * @dev Decodes a role id into a group, if it is a role id of the kind returned by {_encodeGroupRole}.
+     */
+    function _decodeGroupRole(bytes32 role) internal pure virtual returns (bool isGroup, uint8 group) {
+        bytes32 tagMask = ~bytes32(uint256(0xff));
+        bytes32 tag = role & tagMask;
+        isGroup = tag == bytes32("group:");
+        group = uint8(role[31]);
+    }
+
+    /**
+     * @dev Returns a bit mask where the only non-zero bit is the group number bit.
+     */
+    function _groupMask(uint8 group) private pure returns (bytes32) {
+        return bytes32(1 << group);
+    }
+
+    /**
+     * @dev Returns the value of the group number bit in a bitmap.
+     */
+    function _getGroup(bytes32 bitmap, uint8 group) private pure returns (bool) {
+        return bitmap & _groupMask(group) > 0;
+    }
+
+    /**
+     * @dev Returns a new group bitmap where a specific group was updated.
+     */
+    function _withUpdatedGroup(bytes32 bitmap, uint8 group, bool value) private pure returns (bytes32) {
+        bytes32 mask = _groupMask(group);
+        if (value) {
+            return bitmap | mask;
+        } else {
+            return bitmap & ~mask;
+        }
+    }
+}

+ 54 - 0
contracts/access/manager/AccessManagerAdapter.sol

@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "./AccessManager.sol";
+import "./AccessManaged.sol";
+
+/**
+ * @dev This contract can be used to migrate existing {Ownable} or {AccessControl} contracts into an {AccessManager}
+ * system.
+ *
+ * Ownable contracts can have their ownership transferred to an instance of this adapter. AccessControl contracts can
+ * grant all roles to the adapter, while ideally revoking them from all other accounts. Subsequently, the permissions
+ * for those contracts can be managed centrally and with function granularity in the {AccessManager} instance the
+ * adapter is connected to.
+ *
+ * Permissioned interactions with thus migrated contracts must go through the adapter's {relay} function and will
+ * proceed if the function is allowed for the caller in the AccessManager instance.
+ */
+contract AccessManagerAdapter is AccessManaged {
+    bytes32 private constant _DEFAULT_ADMIN_ROLE = 0;
+
+    /**
+     * @dev Initializes an adapter connected to an AccessManager instance.
+     */
+    constructor(AccessManager manager) AccessManaged(manager) {}
+
+    /**
+     * @dev Relays a function call to the target contract. The call will be relayed if the AccessManager allows the
+     * caller access to this function in the target contract, i.e. if the caller is in a team that is allowed for the
+     * function, or if the caller is the default admin for the AccessManager. The latter is meant to be used for
+     * ad hoc operations such as asset recovery.
+     */
+    function relay(address target, bytes memory data) external payable {
+        bytes4 sig = bytes4(data);
+        AccessManager manager = AccessManager(address(authority()));
+        require(
+            manager.canCall(msg.sender, target, sig) || manager.hasRole(_DEFAULT_ADMIN_ROLE, msg.sender),
+            "AccessManagerAdapter: caller not allowed"
+        );
+        (bool ok, bytes memory result) = target.call{value: msg.value}(data);
+        assembly {
+            let result_pointer := add(32, result)
+            let result_size := mload(result)
+            switch ok
+            case true {
+                return(result_pointer, result_size)
+            }
+            default {
+                revert(result_pointer, result_size)
+            }
+        }
+    }
+}

+ 13 - 0
contracts/access/manager/IAuthority.sol

@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+/**
+ * @dev Standard interface for permissioning originally defined in Dappsys.
+ */
+interface IAuthority {
+    /**
+     * @dev Returns true if the caller can invoke on a target the function identified by a function selector.
+     */
+    function canCall(address caller, address target, bytes4 selector) external view returns (bool allowed);
+}

+ 34 - 0
contracts/mocks/AccessManagerMocks.sol

@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.13;
+
+import "../access/manager/IAuthority.sol";
+import "../access/manager/AccessManaged.sol";
+
+contract SimpleAuthority is IAuthority {
+    address _allowedCaller;
+    address _allowedTarget;
+    bytes4 _allowedSelector;
+
+    function setAllowed(address allowedCaller, address allowedTarget, bytes4 allowedSelector) public {
+        _allowedCaller = allowedCaller;
+        _allowedTarget = allowedTarget;
+        _allowedSelector = allowedSelector;
+    }
+
+    function canCall(address caller, address target, bytes4 selector) external view override returns (bool) {
+        return caller == _allowedCaller && target == _allowedTarget && selector == _allowedSelector;
+    }
+}
+
+abstract contract AccessManagedMock is AccessManaged {
+    event RestrictedRan();
+
+    function restrictedFunction() external restricted {
+        emit RestrictedRan();
+    }
+
+    function otherRestrictedFunction() external restricted {
+        emit RestrictedRan();
+    }
+}

+ 1 - 1
hardhat.config.js

@@ -3,7 +3,7 @@
 // - COVERAGE:          enable coverage report
 // - ENABLE_GAS_REPORT: enable gas report
 // - COMPILE_MODE:      production modes enables optimizations (default: development)
-// - COMPILE_VERSION:   compiler version (default: 0.8.9)
+// - COMPILE_VERSION:   compiler version
 // - COINMARKETCAP:     coinmarkercat api key for USD value in gas report
 
 const fs = require('fs');

+ 55 - 0
test/access/manager/AccessManaged.test.js

@@ -0,0 +1,55 @@
+const {
+  expectEvent,
+  expectRevert,
+  constants: { ZERO_ADDRESS },
+} = require('@openzeppelin/test-helpers');
+
+const AccessManaged = artifacts.require('$AccessManagedMock');
+const SimpleAuthority = artifacts.require('SimpleAuthority');
+
+contract('AccessManaged', function (accounts) {
+  const [authority, other, user] = accounts;
+  it('construction', async function () {
+    const managed = await AccessManaged.new(authority);
+    expectEvent.inConstruction(managed, 'AuthorityUpdated', {
+      oldAuthority: ZERO_ADDRESS,
+      newAuthority: authority,
+    });
+    expect(await managed.authority()).to.equal(authority);
+  });
+
+  describe('setAuthority', function () {
+    it(`current authority can change managed's authority`, async function () {
+      const managed = await AccessManaged.new(authority);
+      const set = await managed.setAuthority(other, { from: authority });
+      expectEvent(set, 'AuthorityUpdated', {
+        sender: authority,
+        newAuthority: other,
+      });
+      expect(await managed.authority()).to.equal(other);
+    });
+
+    it(`other account cannot change managed's authority`, async function () {
+      const managed = await AccessManaged.new(authority);
+      await expectRevert(managed.setAuthority(other, { from: other }), 'AccessManaged: not current authority');
+    });
+  });
+
+  describe('restricted', function () {
+    const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()');
+
+    it('allows if authority returns true', async function () {
+      const authority = await SimpleAuthority.new();
+      const managed = await AccessManaged.new(authority.address);
+      await authority.setAllowed(user, managed.address, selector);
+      const restricted = await managed.restrictedFunction({ from: user });
+      expectEvent(restricted, 'RestrictedRan');
+    });
+
+    it('reverts if authority returns false', async function () {
+      const authority = await SimpleAuthority.new();
+      const managed = await AccessManaged.new(authority.address);
+      await expectRevert(managed.restrictedFunction({ from: user }), 'AccessManaged: authority rejected');
+    });
+  });
+});

+ 506 - 0
test/access/manager/AccessManager.test.js

@@ -0,0 +1,506 @@
+const {
+  expectEvent,
+  expectRevert,
+  time: { duration },
+} = require('@openzeppelin/test-helpers');
+const { AccessMode } = require('../../helpers/enums');
+
+const AccessManager = artifacts.require('AccessManager');
+const AccessManagerAdapter = artifacts.require('AccessManagerAdapter');
+const AccessManaged = artifacts.require('$AccessManagedMock');
+
+const Ownable = artifacts.require('$Ownable');
+const AccessControl = artifacts.require('$AccessControl');
+
+const groupUtils = {
+  mask: group => 1n << BigInt(group),
+  decodeBitmap: hexBitmap => {
+    const m = BigInt(hexBitmap);
+    const allGroups = new Array(256).fill().map((_, i) => i.toString());
+    return allGroups.filter(i => (m & groupUtils.mask(i)) !== 0n);
+  },
+  role: group => web3.utils.asciiToHex('group:').padEnd(64, '0') + group.toString(16).padStart(2, '0'),
+};
+
+const PUBLIC_GROUP = '255';
+
+contract('AccessManager', function (accounts) {
+  const [admin, nonAdmin, user1, user2, otherAuthority] = accounts;
+  beforeEach('deploy', async function () {
+    this.delay = duration.days(1);
+    this.manager = await AccessManager.new(this.delay, admin);
+  });
+
+  it('configures default admin rules', async function () {
+    expect(await this.manager.defaultAdmin()).to.equal(admin);
+    expect(await this.manager.defaultAdminDelay()).to.be.bignumber.equal(this.delay);
+  });
+
+  describe('groups', function () {
+    const group = '0';
+    const name = 'dao';
+    const otherGroup = '1';
+    const otherName = 'council';
+
+    describe('public group', function () {
+      it('is created automatically', async function () {
+        await expectEvent.inConstruction(this.manager, 'GroupUpdated', {
+          group: PUBLIC_GROUP,
+          name: 'public',
+        });
+      });
+
+      it('includes all users automatically', async function () {
+        const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1));
+        expect(groups).to.include(PUBLIC_GROUP);
+      });
+    });
+
+    describe('creating', function () {
+      it('admin can create groups', async function () {
+        const created = await this.manager.createGroup(group, name, { from: admin });
+        expectEvent(created, 'GroupUpdated', { group, name });
+        expect(await this.manager.hasGroup(group)).to.equal(true);
+        expect(await this.manager.hasGroup(otherGroup)).to.equal(false);
+      });
+
+      it('non-admin cannot create groups', async function () {
+        await expectRevert(this.manager.createGroup(group, name, { from: nonAdmin }), 'missing role');
+      });
+
+      it('cannot recreate a group', async function () {
+        await this.manager.createGroup(group, name, { from: admin });
+        await expectRevert(this.manager.createGroup(group, name, { from: admin }), 'AccessManager: existing group');
+      });
+    });
+
+    describe('updating', function () {
+      beforeEach('create group', async function () {
+        await this.manager.createGroup(group, name, { from: admin });
+      });
+
+      it('admin can update group', async function () {
+        const updated = await this.manager.updateGroupName(group, otherName, { from: admin });
+        expectEvent(updated, 'GroupUpdated', { group, name: otherName });
+      });
+
+      it('non-admin cannot update group', async function () {
+        await expectRevert(this.manager.updateGroupName(group, name, { from: nonAdmin }), 'missing role');
+      });
+
+      it('cannot update built in group', async function () {
+        await expectRevert(
+          this.manager.updateGroupName(PUBLIC_GROUP, name, { from: admin }),
+          'AccessManager: built-in group',
+        );
+      });
+
+      it('cannot update nonexistent group', async function () {
+        await expectRevert(
+          this.manager.updateGroupName(otherGroup, name, { from: admin }),
+          'AccessManager: unknown group',
+        );
+      });
+    });
+
+    describe('granting', function () {
+      beforeEach('create group', async function () {
+        await this.manager.createGroup(group, name, { from: admin });
+      });
+
+      it('admin can grant group', async function () {
+        const granted = await this.manager.grantGroup(group, user1, { from: admin });
+        expectEvent(granted, 'RoleGranted', { account: user1, role: groupUtils.role(group) });
+        const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1));
+        expect(groups).to.include(group);
+      });
+
+      it('non-admin cannot grant group', async function () {
+        await expectRevert(this.manager.grantGroup(group, user1, { from: nonAdmin }), 'missing role');
+      });
+
+      it('cannot grant nonexistent group', async function () {
+        await expectRevert(this.manager.grantGroup(otherGroup, user1, { from: admin }), 'AccessManager: unknown group');
+      });
+    });
+
+    describe('revoking & renouncing', function () {
+      beforeEach('create and grant group', async function () {
+        await this.manager.createGroup(group, name, { from: admin });
+        await this.manager.grantGroup(group, user1, { from: admin });
+      });
+
+      it('admin can revoke group', async function () {
+        await this.manager.revokeGroup(group, user1, { from: admin });
+        const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1));
+        expect(groups).to.not.include(group);
+      });
+
+      it('non-admin cannot revoke group', async function () {
+        await expectRevert(this.manager.revokeGroup(group, user1, { from: nonAdmin }), 'missing role');
+      });
+
+      it('user can renounce group', async function () {
+        await this.manager.renounceGroup(group, user1, { from: user1 });
+        const groups = groupUtils.decodeBitmap(await this.manager.getUserGroups(user1));
+        expect(groups).to.not.include(group);
+      });
+
+      it(`user cannot renounce other user's groups`, async function () {
+        await expectRevert(
+          this.manager.renounceGroup(group, user1, { from: user2 }),
+          'can only renounce roles for self',
+        );
+        await expectRevert(
+          this.manager.renounceGroup(group, user2, { from: user1 }),
+          'can only renounce roles for self',
+        );
+      });
+
+      it('cannot revoke public group', async function () {
+        await expectRevert(
+          this.manager.revokeGroup(PUBLIC_GROUP, user1, { from: admin }),
+          'AccessManager: irrevocable group',
+        );
+      });
+
+      it('cannot revoke nonexistent group', async function () {
+        await expectRevert(
+          this.manager.revokeGroup(otherGroup, user1, { from: admin }),
+          'AccessManager: unknown group',
+        );
+        await expectRevert(
+          this.manager.renounceGroup(otherGroup, user1, { from: user1 }),
+          'AccessManager: unknown group',
+        );
+      });
+    });
+
+    describe('querying', function () {
+      it('returns expected groups', async function () {
+        const getGroups = () => this.manager.getUserGroups(user1);
+
+        // only public group initially
+        expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000000000');
+
+        await this.manager.createGroup('0', '0', { from: admin });
+        await this.manager.grantGroup('0', user1, { from: admin });
+        expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000000001');
+
+        await this.manager.createGroup('1', '1', { from: admin });
+        await this.manager.grantGroup('1', user1, { from: admin });
+        expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000000003');
+
+        await this.manager.createGroup('16', '16', { from: admin });
+        await this.manager.grantGroup('16', user1, { from: admin });
+        expect(await getGroups()).to.equal('0x8000000000000000000000000000000000000000000000000000000000010003');
+      });
+    });
+  });
+
+  describe('allowing', function () {
+    const group = '1';
+    const groupMember = user1;
+    const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()');
+    const otherSelector = web3.eth.abi.encodeFunctionSignature('otherRestrictedFunction()');
+
+    beforeEach('deploying managed contract', async function () {
+      await this.manager.createGroup(group, '', { from: admin });
+      await this.manager.grantGroup(group, groupMember, { from: admin });
+      this.managed = await AccessManaged.new(this.manager.address);
+    });
+
+    it('non-admin cannot change allowed groups', async function () {
+      await expectRevert(
+        this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, true, { from: nonAdmin }),
+        'missing role',
+      );
+    });
+
+    it('single selector', async function () {
+      const receipt = await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, true, {
+        from: admin,
+      });
+
+      expectEvent(receipt, 'GroupAllowed', {
+        target: this.managed.address,
+        selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4
+        group,
+        allowed: true,
+      });
+
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]);
+
+      const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector);
+      expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([]);
+
+      const restricted = await this.managed.restrictedFunction({ from: groupMember });
+      expectEvent(restricted, 'RestrictedRan');
+
+      await expectRevert(
+        this.managed.otherRestrictedFunction({ from: groupMember }),
+        'AccessManaged: authority rejected',
+      );
+    });
+
+    it('multiple selectors', async function () {
+      const receipt = await this.manager.setFunctionAllowedGroup(
+        this.managed.address,
+        [selector, otherSelector],
+        group,
+        true,
+        { from: admin },
+      );
+
+      expectEvent(receipt, 'GroupAllowed', {
+        target: this.managed.address,
+        selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4
+        group,
+        allowed: true,
+      });
+
+      expectEvent(receipt, 'GroupAllowed', {
+        target: this.managed.address,
+        selector: otherSelector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4
+        group,
+        allowed: true,
+      });
+
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]);
+
+      const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector);
+      expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([group]);
+
+      const restricted = await this.managed.restrictedFunction({ from: groupMember });
+      expectEvent(restricted, 'RestrictedRan');
+
+      await this.managed.otherRestrictedFunction({ from: groupMember });
+      expectEvent(restricted, 'RestrictedRan');
+    });
+
+    it('works on open target', async function () {
+      await this.manager.setContractModeOpen(this.managed.address, { from: admin });
+      await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin });
+    });
+
+    it('works on closed target', async function () {
+      await this.manager.setContractModeClosed(this.managed.address, { from: admin });
+      await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin });
+    });
+  });
+
+  describe('disallowing', function () {
+    const group = '1';
+    const groupMember = user1;
+    const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()');
+    const otherSelector = web3.eth.abi.encodeFunctionSignature('otherRestrictedFunction()');
+
+    beforeEach('deploying managed contract', async function () {
+      await this.manager.createGroup(group, '', { from: admin });
+      await this.manager.grantGroup(group, groupMember, { from: admin });
+      this.managed = await AccessManaged.new(this.manager.address);
+      await this.manager.setFunctionAllowedGroup(this.managed.address, [selector, otherSelector], group, true, {
+        from: admin,
+      });
+    });
+
+    it('non-admin cannot change disallowed groups', async function () {
+      await expectRevert(
+        this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: nonAdmin }),
+        'missing role',
+      );
+    });
+
+    it('single selector', async function () {
+      const receipt = await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, {
+        from: admin,
+      });
+
+      expectEvent(receipt, 'GroupAllowed', {
+        target: this.managed.address,
+        selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4,
+        group,
+        allowed: false,
+      });
+
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([]);
+
+      const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector);
+      expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([group]);
+
+      await expectRevert(this.managed.restrictedFunction({ from: groupMember }), 'AccessManaged: authority rejected');
+
+      const otherRestricted = await this.managed.otherRestrictedFunction({ from: groupMember });
+      expectEvent(otherRestricted, 'RestrictedRan');
+    });
+
+    it('multiple selectors', async function () {
+      const receipt = await this.manager.setFunctionAllowedGroup(
+        this.managed.address,
+        [selector, otherSelector],
+        group,
+        false,
+        { from: admin },
+      );
+
+      expectEvent(receipt, 'GroupAllowed', {
+        target: this.managed.address,
+        selector: selector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4
+        group,
+        allowed: false,
+      });
+
+      expectEvent(receipt, 'GroupAllowed', {
+        target: this.managed.address,
+        selector: otherSelector.padEnd(66, '0'), // there seems to be a bug in decoding the indexed bytes4
+        group,
+        allowed: false,
+      });
+
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([]);
+
+      const otherAllowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, otherSelector);
+      expect(groupUtils.decodeBitmap(otherAllowedGroups)).to.deep.equal([]);
+
+      await expectRevert(this.managed.restrictedFunction({ from: groupMember }), 'AccessManaged: authority rejected');
+      await expectRevert(
+        this.managed.otherRestrictedFunction({ from: groupMember }),
+        'AccessManaged: authority rejected',
+      );
+    });
+
+    it('works on open target', async function () {
+      await this.manager.setContractModeOpen(this.managed.address, { from: admin });
+      await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin });
+    });
+
+    it('works on closed target', async function () {
+      await this.manager.setContractModeClosed(this.managed.address, { from: admin });
+      await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, false, { from: admin });
+    });
+  });
+
+  describe('modes', function () {
+    const group = '1';
+    const selector = web3.eth.abi.encodeFunctionSignature('restrictedFunction()');
+
+    beforeEach('deploying managed contract', async function () {
+      this.managed = await AccessManaged.new(this.manager.address);
+      await this.manager.createGroup('1', 'a group', { from: admin });
+      await this.manager.setFunctionAllowedGroup(this.managed.address, [selector], group, true, { from: admin });
+    });
+
+    it('custom mode is default', async function () {
+      expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Custom);
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]);
+    });
+
+    it('open mode', async function () {
+      const receipt = await this.manager.setContractModeOpen(this.managed.address, { from: admin });
+      expectEvent(receipt, 'AccessModeUpdated', {
+        target: this.managed.address,
+        mode: AccessMode.Open,
+      });
+      expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Open);
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([PUBLIC_GROUP]);
+    });
+
+    it('closed mode', async function () {
+      const receipt = await this.manager.setContractModeClosed(this.managed.address, { from: admin });
+      expectEvent(receipt, 'AccessModeUpdated', {
+        target: this.managed.address,
+        mode: AccessMode.Closed,
+      });
+      expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Closed);
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([]);
+    });
+
+    it('mode cycle', async function () {
+      await this.manager.setContractModeOpen(this.managed.address, { from: admin });
+      await this.manager.setContractModeClosed(this.managed.address, { from: admin });
+      await this.manager.setContractModeCustom(this.managed.address, { from: admin });
+      expect(await this.manager.getContractMode(this.managed.address)).to.bignumber.equal(AccessMode.Custom);
+      const allowedGroups = await this.manager.getFunctionAllowedGroups(this.managed.address, selector);
+      expect(groupUtils.decodeBitmap(allowedGroups)).to.deep.equal([group]);
+    });
+
+    it('non-admin cannot change mode', async function () {
+      await expectRevert(this.manager.setContractModeCustom(this.managed.address), 'missing role');
+      await expectRevert(this.manager.setContractModeOpen(this.managed.address), 'missing role');
+      await expectRevert(this.manager.setContractModeClosed(this.managed.address), 'missing role');
+    });
+  });
+
+  describe('transfering authority', function () {
+    beforeEach('deploying managed contract', async function () {
+      this.managed = await AccessManaged.new(this.manager.address);
+    });
+
+    it('admin can transfer authority', async function () {
+      await this.manager.transferContractAuthority(this.managed.address, otherAuthority, { from: admin });
+      expect(await this.managed.authority()).to.equal(otherAuthority);
+    });
+
+    it('non-admin cannot transfer authority', async function () {
+      await expectRevert(
+        this.manager.transferContractAuthority(this.managed.address, otherAuthority, { from: nonAdmin }),
+        'missing role',
+      );
+    });
+  });
+
+  describe('adapter', function () {
+    const group = '0';
+
+    beforeEach('deploying adapter', async function () {
+      await this.manager.createGroup(group, 'a group', { from: admin });
+      await this.manager.grantGroup(group, user1, { from: admin });
+      this.adapter = await AccessManagerAdapter.new(this.manager.address);
+    });
+
+    it('with ownable', async function () {
+      const target = await Ownable.new();
+      await target.transferOwnership(this.adapter.address);
+
+      const { data } = await target.$_checkOwner.request();
+      const selector = data.slice(0, 10);
+
+      await expectRevert(
+        this.adapter.relay(target.address, data, { from: user1 }),
+        'AccessManagerAdapter: caller not allowed',
+      );
+
+      await this.manager.setFunctionAllowedGroup(target.address, [selector], group, true, { from: admin });
+      await this.adapter.relay(target.address, data, { from: user1 });
+    });
+
+    it('with access control', async function () {
+      const ROLE = web3.utils.soliditySha3('ROLE');
+      const target = await AccessControl.new();
+      await target.$_grantRole(ROLE, this.adapter.address);
+
+      const { data } = await target.$_checkRole.request(ROLE);
+      const selector = data.slice(0, 10);
+
+      await expectRevert(
+        this.adapter.relay(target.address, data, { from: user1 }),
+        'AccessManagerAdapter: caller not allowed',
+      );
+
+      await this.manager.setFunctionAllowedGroup(target.address, [selector], group, true, { from: admin });
+      await this.adapter.relay(target.address, data, { from: user1 });
+    });
+
+    it('transfer authority', async function () {
+      await this.manager.transferContractAuthority(this.adapter.address, otherAuthority, { from: admin });
+      expect(await this.adapter.authority()).to.equal(otherAuthority);
+    });
+  });
+});

+ 1 - 0
test/helpers/enums.js

@@ -9,4 +9,5 @@ module.exports = {
   ProposalState: Enum('Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'),
   VoteType: Enum('Against', 'For', 'Abstain'),
   Rounding: Enum('Down', 'Up', 'Zero'),
+  AccessMode: Enum('Custom', 'Closed', 'Open'),
 };