访问控制 - OpenZeppelin文档

本文深入探讨了智能合约中访问控制的重要性,并介绍了OpenZeppelin Contracts提供的多种访问控制方案,包括Ownable、AccessControl、TimelockController和AccessManager。文章详细阐述了每种方案的原理、使用方法和适用场景,并讨论了如何在实际应用中选择合适的访问控制方案,以确保智能合约的安全性和灵活性,同时还强调了安全实践和潜在风险。

访问控制

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

所有权和 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()
        // 任何人都可以调用 normalThing()
    }

    function specialThing() public onlyOwner {
        // only the owner can call specialThing()!
        // 只有 owner 可以调用 specialThing()!
    }
}

在部署时,Ownable 合约的 owner 被设置为提供的 initialOwner 参数。

Ownable 还允许你:

  • transferOwnership 从 owner 账户转移到新账户,以及

  • renounceOwnership 让 owner 放弃此管理权限,这是在中心化管理的初始阶段结束后常见的模式。

完全移除所有者将意味着受 onlyOwner 保护的管理任务将不再可调用!

Ownable 是一种简单有效的访问控制实现方式,但你应该注意将所有权转移到无法再与此合约交互的不正确账户所带来的危险。此问题的一个替代方案是使用 Ownable2Step;Ownable 的一个变体,它要求新所有者通过调用 acceptOwnership 显式接受所有权转移。

请注意,一个合约也可以是另一个合约的所有者!这为使用例如 Gnosis SafeAragon DAO 创建的完全自定义的合约打开了大门。

通过这种方式,你可以使用可组合性为你的合约添加额外的访问控制复杂性层。例如,你可以使用由你的项目负责人运行的 2-of-3 multisig,而不是将单个常规以太坊账户(外部拥有账户或 EOA)作为所有者。该领域的知名项目,如 MakerDAO,使用的系统与此类似。

基于角色的访问控制

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

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

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

使用 AccessControl

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

这是一个在 ERC-20 token 中使用 AccessControl 定义“minter”角色的简单示例,该角色允许拥有它的账户创建新的 token:

// 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
    // 为 minter 角色创建一个新的角色标识符
    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
        // 将 minter 角色授予指定的账户
        _grantRole(MINTER_ROLE, minter);
    }

    function mint(address to, uint256 amount) public {
        // Check that the calling account has the minter role
        // 检查调用账户是否具有 minter 角色
        if (!hasRole(MINTER_ROLE, msg.sender)) {
            revert CallerNotMinter(msg.sender);
        }
        _mint(to, amount);
    }
}
在你的系统中使用 AccessControl 或从本指南中复制粘贴示例之前,请确保你完全了解其工作原理。

虽然清晰明了,但这并不是我们无法使用 Ownable 实现的任何东西。实际上,AccessControl 的亮点在于需要细粒度权限的场景,可以通过定义 多个 角色来实现。

让我们通过定义一个“burner”角色来增强我们的 ERC-20 token 示例,该角色允许账户销毁 token,并通过使用 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);
    }
}

如此干净!通过以这种方式拆分关注点,可以实现比使用更简单的所有权方法进行访问控制更细粒度的权限级别。限制系统每个组件能够执行的操作被称为最小权限原则,并且是一种良好的安全措施。请注意,如果需要,每个账户仍然可以拥有多个角色。

授予和撤销角色

上面的 ERC-20 token 示例使用 _grantRole,这是一个 internal 函数,在以编程方式分配角色时(例如在构造期间)很有用。但是,如果我们以后想将“minter”角色授予其他账户怎么办?

默认情况下,具有角色的账户无法授予角色或从其他账户撤销角色:拥有角色所做的只是使 hasRole 检查通过。要动态地授予和撤消角色,你需要角色的管理员的帮助。

每个角色都有一个关联的管理角色,该角色授予调用 grantRolerevokeRole 函数的权限。如果调用账户具有相应的管理角色,则可以使用这些函数授予或撤消角色。多个角色可以具有相同的管理角色,以简化管理。角色的管理员甚至可以是角色本身,这将导致具有该角色的账户也能够授予和撤消该角色。

这种机制可用于创建类似于组织结构图的复杂权限结构,而且它还提供了一种管理更简单应用程序的简便方法。AccessControl 包括一个名为 DEFAULT_ADMIN_ROLE 的特殊角色,该角色充当所有角色的默认管理角色。除非使用 _setRoleAdmin 选择新的管理角色,否则具有此角色的账户将能够管理任何其他角色。

由于默认情况下它是所有角色的管理员,并且实际上它也是自己的管理员,因此该角色具有重大风险。为了降低这种风险,我们提供了 AccessControlDefaultAdminRules,这是 AccessControl 的推荐扩展,它为此角色添加了许多强制执行的安全措施:管理员仅限于单个账户,具有两步转移程序,步骤之间有延迟。

让我们看一下 ERC-20 token 示例,这次利用默认的管理角色:

// 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);
    }
}

请注意,与之前的示例不同,没有账户被授予“minter”或“burner”角色。但是,由于这些角色的管理角色是默认的管理角色,并且_该_角色被授予给 msg.sender,因此相同的账户可以调用 grantRole 来授予铸造或销毁权限,并调用 revokeRole 来删除它。

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

查询特权账户

由于账户可能会动态地授予和撤消角色,因此并非总是可以确定哪些账户拥有特定角色。这很重要,因为它允许证明有关系统的某些属性,例如管理账户是 multisig 还是 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));
}

延迟操作

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

这正是 TimelockController 正在解决的问题。

TimelockController 是一个代理,由提议者和执行者管理。当设置为智能合约的所有者/管理员/控制器时,它确保提议者订购的任何维护操作都受到延迟。这种延迟通过让用户有时间审查维护操作并在他们认为符合自身最佳利益的情况下退出系统来保护智能合约的用户。

使用 TimelockController

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

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

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

AccessControl 之上构建了一个附加功能:将执行者角色授予 address(0) 允许任何人在时钟锁过期后执行提案。此功能虽然有用,但应谨慎使用。

此时,在分配了提议者和执行者的情况下,时钟锁可以执行操作。

一个可选的下一步是部署者放弃其管理权限,并让时钟锁自行管理。如果部署者决定这样做,则所有进一步的维护,包括分配新的提议者/计划程序或更改时钟锁持续时间,都必须遵循时钟锁工作流程。这会将时钟锁的治理与连接到时钟锁的合约的治理联系起来,并强制对时钟锁维护操作进行延迟。

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

在分配了提议者和执行者角色并由时钟锁负责其自身的管理后,你现在可以将任何合约的所有权/控制权转移到时钟锁。

建议的配置是将这两个角色都授予安全的治理合约,例如 DAO 或 multisig,并另外将执行者角色授予由负责帮助维护操作的人员持有的几个 EOA。这些钱包无法接管时钟锁的控制权,但它们可以帮助简化工作流程。

最短延迟

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

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

访问管理

对于合约系统,可以使用 AccessManager 实例来实现更好集成的角色管理。AccessManager 不是单独管理每个合约的权限,而是将所有权限存储在单个合约中,从而使你的协议更易于审核和维护。

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

访问控制多个

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

AccessManager

AccessManager 围绕角色和目标函数的概念设计:

  • 角色被授予账户(地址),遵循多对多的方法以获得更大的灵活性。这意味着每个用户可以拥有一个或多个角色,并且多个用户可以拥有相同的角色。

  • 对受限目标函数的访问仅限于一个角色。目标函数由一个合约(称为目标)上的一个函数选择器定义。

为了使调用获得授权,调用者必须具有分配给当前目标函数(合约地址 + 函数选择器)的角色。

AccessManager 函数

使用 AccessManager

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

为了限制对你的合约的某些函数的访问,你应该继承自与管理器一起提供的 AccessManaged 合约。这提供了 restricted 修饰符,可用于保护任何面向外部的函数。请注意,你必须在构造函数中指定 AccessManager 实例的地址(initialAuthority),以便 restricted 修饰符知道使用哪个管理器来检查权限。

这是一个简单的 ERC-20 token 示例,它定义了一个 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.
    // 该函数由其选择器标识:0x40c10f19。
    // Calculated with bytes4(keccak256('mint(address,uint256)'))
    // 使用 bytes4(keccak256('mint(address,uint256)')) 计算
    function mint(address to, uint256 amount) public restricted {
        _mint(to, amount);
    }
}
在使用 AccessManager 或从本指南中复制粘贴示例之前,请确保你完全了解其工作原理。

一旦部署了受管理合约,它现在就处于管理器的控制之下。然后,初始管理员可以将 minter 角色分配给一个地址,并允许该角色调用 mint 函数。例如,以下 Javascript 代码使用 Ethers.js 演示了这一点:

// const target = ...;
// const user = ...;
const MINTER = 42n; // Roles are uint64 (0 is reserved for the ADMIN_ROLE)
// 角色是 uint64 (0 保留给 ADMIN_ROLE)

// Grant the minter role with no execution delay
// 授予 minter 角色,没有执行延迟
await manager.grantRole(MINTER, user, 0);

// Allow the minter role to call the function selector
// 允许 minter 角色调用与 mint 函数对应的函数选择器
// corresponding to the mint function
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);

// The role will go into effect after the GRANT_DELAY passes
// 该角色将在 GRANT_DELAY 过去后生效
await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY);

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

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

鉴于 AccessManaged 的管理员可以修改其所有权限,建议仅在 multisig 或治理层下保留一个受保护的管理员地址。为了实现这一点,初始管理员可以设置所有必需的权限、目标和函数,分配一个新的管理员,最后放弃其管理员角色。

为了改进事件响应协调,管理器包括一种模式,管理员可以完全关闭目标合约。关闭后,对目标合约中受限目标函数的所有调用都将恢复。

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

此模式对于需要暂时关闭合约以评估紧急情况和重新配置权限的事件响应操作非常有用。

const target = await myToken.getAddress();

// Token's `restricted` functions closed
// Token 的“受限”函数已关闭
await manager.setTargetClosed(target, true);

// Token's `restricted` functions open
// Token 的“受限”函数已打开
await manager.setTargetClosed(target, false);
即使 AccessManager 定义了目标函数的权限,如果受管理合约实例没有为该函数使用 restricted 修饰符,或者其管理器是不同的管理器,则这些权限将不会应用。

角色管理员和守护者

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

AccessManager 的情况下,同样的规则适用,只有角色的管理员才能调用 grantrevoke 函数。请注意,调用这些函数将受到执行角色管理员拥有的执行延迟的影响。

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

小心 ADMIN_ROLE 的成员,因为它充当每个角色的默认管理员和守护者。行为不端的守护者可以随意取消操作,从而影响 AccessManager 的操作。

管理器配置

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

此配置接口包括以下函数:

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

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

延迟的管理员操作是:

与 Ownable 一起使用

已经从 Ownable 继承的合约可以通过将所有权转移给管理器来迁移到 AccessManager。之后,即使调用者不需要延迟,也应通过管理器的 execute 函数调用对具有 onlyOwner 修饰符的函数的所有调用。

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

与 AccessControl 一起使用

对于已经使用 AccessControl 的系统,可以在撤销所有其他角色后将 DEFAULT_ADMIN_ROLE 授予给 AccessManager。后续调用应通过管理器的 execute 方法进行,类似于 Ownable 情况。

// Revoke old roles
// 撤销旧角色
await accessControl.connect(admin).revokeRole(MINTER_ROLE, account);

// Grant the admin role to the access manager
// 将管理员角色授予访问管理器
await accessControl.connect(admin).grantRole(DEFAULT_ADMIN_ROLE, accessManager);

await accessControl.connect(admin).renounceRole(DEFAULT_ADMIN_ROLE, admin);

← 向后兼容性

Tokens →

  • 原文链接: docs.openzeppelin.com/co...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。