Openzeppelin-Access Control

jerichou
2025-10-28 / 0 评论 / 1 阅读 / 正在检测是否收录...

一、Access Control

访问控制——即“谁被允许做这件事”——在智能合约的世界中至关重要。您的合约的访问控制可能决定谁能铸造代币、对提案进行投票、冻结转账以及其他许多事情。因此,理解如何实现它是至关重要的,以免其他人窃取您的整个系统。

1.Ownership and Ownable 所有权和 Ownable

最常见和最基本的访问控制形式是所有权的概念:有一个账户是合约的 owner ,可以在其上执行管理任务。对于只有一个管理用户的合约来说,这种方法是完全可以接受的。
OpenZeppelin Contracts 提供了 Ownable 来实现您合约中的所有权。

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyContract is Ownable {
    constructor(address initialOwner) Ownable(initialOwner) {}

    function normalThing() public {
        // anyone can call this normalThing()
    }

    function specialThing() public onlyOwner {
        // only the owner can call specialThing()!
    }
}

部署时, Ownable 合约的 owner 被设置为提供的 initialOwner 参数。
Ownable 还允许你:

  • transferOwnership 从所有者账户转移到新的账户,以及
  • renounceOwnership让所有者放弃这个管理权限,在集中式管理阶段结束后,这是一个常见模式。
    完全移除所有者意味着受 onlyOwner 保护的管理任务将不再可调用!

Ownable 是一种简单而有效的访问控制实现方式,但您应注意将所有权转移到一个无法再与此合约交互的错误账户所带来的风险。解决这个问题的一个替代方案是使用 Ownable2Step ;一种 Ownable 变体,要求新所有者通过调用 acceptOwnership 明确接受所有权转移。
请注意,合约也可以是另一个合约的所有者!这为使用例如 Gnosis Safe、Aragon DAO 或您自己创建的完全自定义合约打开了大门。
通过这种方式,您可以使用组合性为您的合约添加额外的访问控制复杂层。您不必使用单个常规以太坊账户(外部所有账户,或 EOA)作为所有者,而是可以使用由您的项目领导者运行的 2-of-3 多重签名,例如。该领域著名的项目,如 MakerDAO,使用类似此系统的方案。

2.Role-Based Access Control 基于角色的访问控制

虽然所有权的简单性对于简单系统或快速原型设计可能很有用,但通常需要不同级别的授权。您可能希望一个账户有权限从系统中禁止用户,但不创建新代币。基于角色的访问控制(RBAC)在这方面提供了灵活性。

本质上,我们将定义多个角色,每个角色允许执行不同的操作集。例如,一个账户可能有“moderator”、“minter”或“admin”角色,然后您将检查这些角色,而不是简单地使用 onlyOwner 。这个检查可以通过 onlyRole 修饰符来强制执行。另外,您将能够定义规则,说明如何授予账户角色、撤销角色等。

大多数软件使用基于角色的访问控制系统:一些用户是普通用户,一些可能是supervisors或者managers,而少数通常具有管理员权限。

3.Using AccessControl 使用 `AccessControl

OpenZeppelin Contracts 提供 AccessControl 用于实现基于角色的访问控制。其使用方法非常简单:对于您想要定义的每个角色,您将创建一个新的角色标识符,该标识符用于授予、撤销和检查一个账户是否具有该角色。

这里有一个使用 AccessControl 在 ERC-20 代币中定义“铸造者”角色的简单示例,该角色允许拥有该角色的账户创建新的代币:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract AccessControlERC20MintBase is ERC20, AccessControl {
    // Create a new role identifier for the minter role
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    error CallerNotMinter(address caller);

    constructor(address minter) ERC20("MyToken", "TKN") {
        // Grant the minter role to a specified account
        _grantRole(MINTER_ROLE, minter);
    }

    function mint(address to, uint256 amount) public {
        // Check that the calling account has the minter role
        if (!hasRole(MINTER_ROLE, msg.sender)) {
            revert CallerNotMinter(msg.sender);
        }
        _mint(to, amount);
    }
}

在使用 AccessControl 在您的系统上之前,请确保您完全理解其工作原理,或者从本指南中复制粘贴示例。
虽然清晰明确,但这并不是我们无法通过 Ownable 实现的事情。事实上, AccessControl 的优势在于需要细粒度权限的场景,这可以通过定义多个角色来实现。

让我们通过定义一个“销毁者”角色来增强我们的 ERC-20 代币示例,该角色允许账户销毁代币,并使用 onlyRole 修饰符:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract AccessControlERC20Mint is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor(address minter, address burner) ERC20("MyToken", "TKN") {
        _grantRole(MINTER_ROLE, minter);
        _grantRole(BURNER_ROLE, burner);
    }

    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

太干净了!通过这种方式分离关注点,可以实现比简单的所有权方法访问控制更细粒度的权限级别。限制系统每个组件能够做什么被称为最小权限原则,这是一种良好的安全实践。请注意,每个账户仍然可以有多个角色,如果需要的话。

4.Granting and Revoking Roles 授权和撤销角色

上述的 ERC-20 代币示例使用了 _grantRole ,这是一个在程序化分配角色(如在构造期间)时非常有用的 internal 函数。但如果我们后来想将'minter'角色授予其他账户,该怎么办?

默认情况下,具有角色的账户不能将其授予或撤销给其他账户:拥有角色仅意味着让 hasRole 检查通过。若要动态地授予和撤销角色,您需要角色的管理员协助。

每个角色都有一个关联的 admin 角色,该角色拥有调用 grantRolerevokeRole 函数的权限。如果调用账户具有相应的 admin 角色,则可以通过这些函数来授予或撤销角色。为了便于管理,多个角色可以拥有相同的 admin 角色。一个角色的 admin 甚至可以是该角色本身,这将导致具有该角色的账户也能够授予和撤销该角色。

这种机制可用于创建类似于组织结构的复杂权限结构,但它也提供了一种管理简单应用的方法。 AccessControl 包含一个特殊角色,称为 DEFAULT_ADMIN_ROLE ,它作为所有角色的默认管理员角色。具有此角色的账户将能够管理任何其他角色,除非使用 _setRoleAdmin 选择一个新的管理员角色。

由于它默认是所有角色的管理员,实际上它也是自己的管理员,这个角色具有显著的风险。为了降低这种风险,我们提供了 AccessControlDefaultAdminRules ,它是 AccessControl 的一个推荐扩展,为这个角色添加了一系列强制安全措施:管理员被限制在一个账户上,并且需要经过一个带有步骤间延迟的两步转账程序。

让我们看看 ERC-20 代币示例,这次利用默认的管理员角色:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract AccessControlERC20MintMissing is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("MyToken", "TKN") {
        // Grant the contract deployer the default admin role: it will be able
        // to grant and revoke any roles
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

请注意,与前面的示例不同,没有账户被授予“铸造者”或“销毁者”角色。然而,因为这些角色的管理员角色是默认管理员角色,并且该角色已授予 msg.sender ,该账户可以调用 grantRole 来授予铸造或销毁权限,并调用 revokeRole 来移除这些权限。

动态角色分配通常是一个理想的特性,例如在信任度可能随时间变化的参与者的系统中。它还可以用于支持 KYC 等用例,其中角色持有者列表可能事先未知,或者包含在单个交易中可能成本过高。

5.Querying Privileged Accounts 查询特权账户

由于账户可能会动态地授予和撤销角色,因此无法总是确定哪些账户持有特定角色。这很重要,因为它允许证明系统的一些特定属性,例如管理账户是多签或 DAO,或者某个角色已被从所有用户中移除,从而有效地禁用任何相关功能。

在底层, AccessControl 使用了 EnumerableSet ,这是 Solidity 的 mapping 类型的更强大的变体,它允许进行键枚举。可以使用 getRoleMemberCount 来获取具有特定角色的账户数量,然后调用 getRoleMember 来获取这些账户的地址。

const minterCount = await myToken.getRoleMemberCount(MINTER_ROLE);

const members = [];
for (let i = 0; i < minterCount; ++i) {
    members.push(await myToken.getRoleMember(MINTER_ROLE, i));
}

6.Delayed operation 延迟操作

访问控制对于防止未经授权访问关键功能至关重要。这些功能可用于铸造代币、冻结转账或执行完全改变智能合约逻辑的升级。虽然 OwnableAccessControl 可以防止未经授权的访问,但它们并未解决不良管理员攻击其自身系统损害用户利益的问题。

这就是 TimelockController 所要解决的问题。

TimelockController 是一个由提议者(proposers)和执行者(executors)管理的代理。当设置为智能合约的所有者/管理员/控制器时,它确保提议者所下达的任何维护操作都会受到延迟。这种延迟通过给予用户时间来审查维护操作,并在他们认为这样做符合自身利益时退出系统,从而保护智能合约的用户。

7.Using TimelockController 使用 `TimelockController

默认情况下,部署 TimelockController 的地址将获得对时间锁的管理权限。此角色赋予分配提议者、执行者和其他管理者的权利。

配置 TimelockController 的第一步是为至少一个提议者和一个执行者分配角色。这些角色可以在构造时分配,或稍后由具有管理员角色的任何人分配。这些角色不是排他的,这意味着一个账户可以同时拥有这些角色。

角色通过 AccessControl 接口进行管理,每个角色的 bytes32 值可以通过 ADMIN_ROLEPROPOSER_ROLEEXECUTOR_ROLE 常量访问。

AccessControl 之上还有一个附加功能:将执行者角色赋予 address(0) ,在时间锁过期后,任何人都可以执行提案。虽然这个功能很有用,但应该谨慎使用。
此时,由于已经分配了提议者和执行者,时间锁可以执行操作。

一个可选的后续步骤是部署者放弃其管理权限,并将时间锁自我管理。如果部署者决定这样做,所有进一步的维护工作,包括指派新的提议者/调度者或更改时间锁持续时间,都必须遵循时间锁工作流程。这将时间锁的治理与连接到时间锁的合约的治理联系起来,并对时间锁维护操作实施延迟。

如果部署者放弃管理权以支持时间锁本身,指派新的提议者或执行者将需要时间锁操作。这意味着如果负责这两个角色中的任何一个的账户变得不可用,那么整个合约(以及它控制的任何合约)将无限期锁定。

在提议者和执行者角色分配完毕,并且时间锁负责其自身管理的情况下,您现在可以将任何合约的所有权/控制权转移给时间锁。

推荐配置是将这两个角色授予一个安全的治理合约,例如 DAO 或多签合约,并额外将执行者角色授予由负责协助维护操作的人员持有的几个 EOA。这些钱包不能接管时间锁的控制,但可以帮助使工作流程更加顺畅。

8.Minimum delay 最小延迟

TimelockController 执行的操作不受固定延迟的限制,而是受最小延迟的限制。某些重大更新可能需要更长的延迟。例如,如果几天的时间就足以让用户审计一次铸造操作,那么在安排智能合约升级时,使用几周甚至几个月的延迟是合理的。

最小延迟(可通过 getMinDelay 方法访问)可以通过调用 updateDelay 函数进行更新。请记住,只有时间锁本身才能访问此函数,这意味着此维护操作必须通过时间锁本身进行。

9.Access Management 访问管理

对于合约系统,使用 AccessManager 实例可以实现更好的角色集成管理。与分别管理每个合约的权限不同,AccessManager 将所有权限存储在一个合约中,使您的协议更容易进行审计和维护。

尽管 AccessControl 比 Ownable 为您的合约添加权限提供了更动态的解决方案,但在集成新的合约实例后,去中心化协议往往会变得更加复杂,并且需要您在每个合约中分别跟踪权限。这增加了权限管理和监控的复杂性。

Access Control multiple

生产系统中的权限管理协议通常需要更集成的替代方案,以应对通过多个 AccessControl 实例产生的碎片化权限。

AccessManager

AccessManager 的设计围绕角色和目标功能的概念展开:

  • 角色以多对多的方式授予账户(地址),以实现灵活性。这意味着每个用户可以有一个或多个角色,多个用户可以拥有相同的角色。
  • 对受限目标函数的访问仅限于一个角色。目标函数由一个合约(称为目标)上的一个函数选择器定义。

要授权一个调用,调用者必须拥有分配给当前目标函数(合约地址 + 函数选择器)的角色。

AccessManager functions

10.Using AccessManager 使用 `AccessManager

OpenZeppelin Contracts 提供 AccessManager 用于跨任意数量合约管理角色。 AccessManager 本身是一个可以即部署即用的合约。它在构造函数中设置了一个初始管理员,该管理员将被允许执行管理操作。

为了限制对合约某些函数的访问,您应该继承与管理者一起提供的 AccessManaged 合约。这提供了 restricted 修饰符,可用于保护任何对外部可见的函数。请注意,您必须在构造函数中指定 AccessManager 实例的地址( initialAuthority ),以便 restricted 修饰符知道使用哪个管理者来检查权限。
这里是一个简单的 ERC-20 代币示例,它定义了一个 mint 函数,该函数受到 AccessManager 的限制:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {AccessManaged} from "@openzeppelin/contracts/access/manager/AccessManaged.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract AccessManagedERC20Mint is ERC20, AccessManaged {
    constructor(address manager) ERC20("MyToken", "TKN") AccessManaged(manager) {}

    // Minting is restricted according to the manager rules for this function.
    // The function is identified by its selector: 0x40c10f19.
    // Calculated with bytes4(keccak256('mint(address,uint256)'))
    function mint(address to, uint256 amount) public restricted {
        _mint(to, amount);
    }
}

在使用 AccessManager 之前或从本指南中复制粘贴示例之前,请确保您完全理解 AccessManager 的工作原理。

一旦托管合约部署完成,它就处于管理者的控制之下。初始管理员可以将铸造者角色分配给一个地址,并允许该角色调用 mint 函数。例如,以下是用 Ethers.js 编写的 JavaScript 代码演示了这一点:

const MINTER = 42n; // Roles are uint64 (0 is reserved for the ADMIN_ROLE)

await manager.grantRole(MINTER, user, 0);

await manager.setTargetFunctionRole(
    target,
    ['0x40c10f19'], // bytes4(keccak256('mint(address,uint256)'))
    MINTER
);

尽管每个角色都有其自身的功能权限列表,但每个角色成员( address )都有一个执行延迟,这将决定账户在执行需要其角色的功能时需要等待多长时间。延迟操作必须在 AccessManager 中先调用 schedule 函数,然后才能执行,无论是调用目标函数还是使用 AccessManager 的 execute 函数。
此外,角色可以设置授权延迟,以防止立即添加成员。AccessManager 管理员可以按以下方式设置此授权延迟:

const HOUR = 60 * 60;

const GRANT_DELAY = 24 * HOUR;
const EXECUTION_DELAY = 5 * HOUR;
const ACCOUNT = "0x...";

await manager.connect(initialAdmin).setGrantDelay(MINTER, GRANT_DELAY);

await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY);

请注意,角色不定义名称。与 AccessControl 情况相反,角色被识别为数值,而不是在合约中以 bytes32 值硬编码。仍然可以使用 labelRole 函数通过角色标签来允许工具发现(例如用于角色探索)。

await manager.labelRole(MINTER, "MINTER");

由于 AccessManaged 的管理员可以修改所有权限,建议仅将一个管理员地址安全地保存在多重签名或治理层下。为此,初始管理员可以设置所有必需的权限、目标和函数,分配新的管理员,并最终放弃其管理员角色。

为了改进事件响应协调,管理员模式允许完全关闭目标合约。当合约关闭时,所有对目标合约中受限目标函数的调用都将回滚。

关闭和开启合约不会更改任何设置,无论是权限还是延迟。特别是,调用特定目标函数所需的角色不会被修改。

这种模式适用于需要暂时关闭合约以评估紧急情况并重新配置权限的事件响应操作。

const target = await myToken.getAddress();await manager.setTargetClosed(target, true);await manager.setTargetClosed(target, false);

即使一个 AccessManager 为目标函数定义了权限,如果管理的合约实例没有使用 restricted 修饰符,或者其管理者不同,这些权限也不会被应用。

11.Role Admins and Guardians 角色管理员和守护者

AccessControl 合约的一个重要方面是角色成员不会授予或撤销角色。相反,它依赖于角色管理员的概念来授予和撤销。

对于 AccessManager ,同样适用此规则,只有该角色的管理员能够调用 grant 和 revoke 函数。请注意,调用这些函数将受到执行角色管理员执行延迟的影响。

此外, AccessManager 还为每个角色存储一个守护者作为额外的保护。这个守护者有能力取消任何角色成员已安排的、有执行延迟的操作。考虑一个角色将默认将其初始管理员和守护者设置为 ADMIN_ROLE0 )。

ADMIN_ROLE 的成员要小心,因为它作为每个角色的默认管理员和监护人。行为不当的监护人可以随意取消操作,影响 AccessManager 的运行。

12.Manager configuration 管理器配置

AccessManager 提供了一个内置界面,用于配置权限设置,其 ADMIN_ROLE 成员可以访问。

此配置界面包括以下功能:

  • 使用 labelRole 函数为角色添加标签。
  • 为角色分配管理员和监护人,使用 setRoleAdminsetRoleGuardian
  • 通过 setGrantDelay 设置每个角色的授权延迟。

作为管理员,某些操作将需要延迟。类似于每个成员的执行延迟,一些管理员操作需要等待执行,并应遵循 scheduleexecute 工作流程。

更具体地说,这些延迟函数是用于配置特定目标合约设置的函数。这些函数应用的延迟可以通过管理员使用 setTargetAdminDelay 调整。

延迟的管理员操作包括:

  • 更新 AccessManaged 合约的授权方使用 updateAuthority
  • 通过 setTargetClosed 关闭或打开目标。
  • 通过 setTargetFunctionRole 更改权限,以决定角色是否可以调用目标函数。

13.Using with Ownable 与 Ownable 一起使用

已经从 Ownable 继承的合约可以通过将所有权转移给管理员迁移到 AccessManager。之后,所有带有 onlyOwner 修饰符的函数调用都应该通过管理员的自 execute 函数进行,即使调用者不需要延迟。

await ownable.connect(owner).transferOwnership(accessManager);

14.Using with AccessControl 与 AccessControl 一起使用

For systems already using AccessControl, the DEFAULT_ADMIN_ROLE can be granted to the AccessManager after revoking every other role. Subsequent calls should be made through the manager’s execute method, similar to the Ownable case.
对于已经使用 AccessControl 的系统,在撤销所有其他角色后,可以将 DEFAULT_ADMIN_ROLE 授予 AccessManager 。后续调用应该通过管理员的自 execute 方法进行,类似于 Ownable 的情况。

await accessControl.connect(admin).revokeRole(MINTER_ROLE, account);await accessControl.connect(admin).grantRole(DEFAULT_ADMIN_ROLE, accessManager);await accessControl.connect(admin).renounceRole(DEFAULT_ADMIN_ROLE, admin);
1

评论 (0)

取消