本文档介绍了智能合约中访问控制的重要性,并详细讲解了OpenZeppelin提供的Ownable和AccessControl组件来实现不同级别的访问控制。Ownable适用于简单的所有权管理,而AccessControl则通过角色定义,提供更灵活的权限控制。此外,还介绍了使用TimelockController实现延迟操作,以防止恶意管理员行为,保护用户利益。
你当前阅读的不是此文档的最新版本。5.x 是当前版本。
访问控制——即 “谁被允许做这件事”——在智能合约领域至关重要。你的合约的访问控制可能决定了谁可以铸造代币、对提案进行投票、冻结转账以及许多其他事情。因此,理解如何实现它是至关重要的,否则其他人可能会窃取你的整个系统。
Ownable
最常见和最基本的访问控制形式是所有权的概念:存在一个账户是合约的 owner
,并且可以对其执行管理任务。对于具有单个管理用户的合约,这种方法是完全合理的。
OpenZeppelin 提供了 Ownable
用于在你的合约中实现所有权。
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyContract is Ownable {
function normalThing() public {
// 任何人都可以调用此 normalThing()
}
function specialThing() public onlyOwner {
// 只有所有者可以调用 specialThing()!
}
}
默认情况下,Ownable
合约的 owner
是部署它的账户,这通常正是你想要的。
Ownable
还允许你:
从所有者账户 transferOwnership
到一个新的账户,以及
renounceOwnership
供所有者放弃此管理权限,这是在集中管理的初始阶段结束后常见的模式。
完全移除所有者将意味着受 onlyOwner 保护的管理任务将无法再被调用! |
请注意,合约也可以是另一个合约的所有者!这为使用例如 Gnosis Multisig 或 Gnosis Safe, 一个 Aragon DAO, 一个 ERC725/uPort 身份合约, 或者一个 你 创建的完全自定义的合约打开了大门。
通过这种方式,你可以使用可组合性来为你的合约添加额外的访问控制复杂性层次。例如,你可以使用由你的项目负责人运行的 2-of-3 多重签名,而不是使用单个常规的以太坊账户(外部拥有账户,或 EOA)作为所有者。该领域的突出项目,例如 MakerDAO,使用与此类似的系统。
虽然所有权的简单性对于简单系统或快速原型设计可能很有用,但通常需要不同的授权级别。你可能希望某个账户具有禁止用户使用系统的权限,但不能创建新代币。基于角色的访问控制(RBAC) 在这方面提供了灵活性。
本质上,我们将定义多个角色,每个角色都允许执行不同的操作集。一个帐户可能具有例如“moderator”、“minter”或“admin”角色,然后你将检查这些角色,而不是仅仅使用 onlyOwner
。此外,你将能够定义关于如何授予帐户角色、撤销角色的规则等等。
大多数软件使用基于角色的访问控制系统:一些用户是普通用户,一些可能是主管或经理,少数人通常具有管理权限。
AccessControl
OpenZeppelin Contracts 提供了 AccessControl
用于实现基于角色的访问控制。它的用法很简单:对于你要定义的每个角色,你将创建一个新的角色标识符,该标识符用于授予、撤销和检查帐户是否具有该角色。
这是一个在 ERC20
代币 中使用 AccessControl
定义 “minter” 角色的简单示例,该角色允许拥有该角色的帐户创建新代币:
// contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20, AccessControl {
// 为 minter 角色创建一个新的角色标识符
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor(address minter) public ERC20("MyToken", "TKN") {
// 将 minter 角色授予指定的账户
_setupRole(MINTER_ROLE, minter);
}
function mint(address to, uint256 amount) public {
// 检查调用帐户是否具有 minter 角色
require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
_mint(to, amount);
}
}
在你的系统中使用 AccessControl 之前,或者从本指南中复制粘贴示例之前,请确保你完全理解它的工作原理。 |
虽然清晰而明确,但这并不是我们无法通过 Ownable
实现的任何东西。事实上,AccessControl
的亮点在于需要细粒度权限的场景,这可以通过定义多个角色来实现。
让我们通过定义一个 'burner' 角色来增强我们的 ERC20 代币示例,该角色允许帐户销毁代币:
// contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
constructor(address minter, address burner) public ERC20("MyToken", "TKN") {
_setupRole(MINTER_ROLE, minter);
_setupRole(BURNER_ROLE, burner);
}
function mint(address to, uint256 amount) public {
require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
_mint(to, amount);
}
function burn(address from, uint256 amount) public {
require(hasRole(BURNER_ROLE, msg.sender), "Caller is not a burner");
_burn(from, amount);
}
}
太简洁了!通过这种方式分离关注点,可以实现比使用更简单的所有权访问控制方法所能实现的更细粒度的权限级别。限制系统的每个组件能够做什么被称为最小权限原则,这是一种良好的安全实践。请注意,如果需要,每个帐户仍然可以拥有多个角色。
上面的 ERC20 代币示例使用了 _setupRole
,一个在以编程方式分配角色(例如在构造期间)时有用的 internal
函数。但是,如果我们稍后想要向其他帐户授予 “minter” 角色怎么办?
默认情况下,具有角色的帐户无法授予该角色或从其他帐户撤销该角色:拥有角色所做的只是使 hasRole
检查通过。要动态地授予和撤销角色,你需要来自角色的管理员的帮助。
每个角色都有一个关联的管理角色,该角色授予调用 grantRole
和 revokeRole
函数的权限。如果调用帐户具有相应的管理角色,则可以使用这些角色授予或撤销角色。多个角色可能具有相同的管理角色,以简化管理。角色的管理员甚至可以是相同的角色本身,这将导致具有该角色的帐户也能够授予和撤销它。
此机制可用于创建类似于组织结构图的复杂权限结构,但它也提供了一种管理更简单应用程序的简单方法。AccessControl
包括一个名为 DEFAULT_ADMIN_ROLE
的特殊角色,该角色充当所有角色的默认管理角色。除非使用 _setRoleAdmin
选择新的管理角色,否则具有此角色的帐户将能够管理任何其他角色。
让我们看一下 ERC20 代币示例,这次是利用默认的管理角色:
// contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
constructor() public ERC20("MyToken", "TKN") {
// 授予合约部署者默认的管理角色:它将能够
// 授予和撤销任何角色
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
}
function mint(address to, uint256 amount) public {
require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
_mint(to, amount);
}
function burn(address from, uint256 amount) public {
require(hasRole(BURNER_ROLE, msg.sender), "Caller is not a burner");
_burn(from, amount);
}
}
请注意,与之前的示例不同,没有帐户被授予 “minter” 或 “burner” 角色。但是,由于这些角色的管理角色是默认的管理角色,并且_该_角色已授予 msg.sender
,因此同一帐户可以调用 grantRole
以授予铸造或销毁权限,并调用 revokeRole
以删除它。
动态角色分配通常是理想的属性,例如在对参与者的信任可能随时间变化的系统中。它还可以用于支持用例,例如 KYC,其中角色持有者的列表可能不是预先知道的,或者将其包含在单个交易中可能过于昂贵。
因为账户可能动态地授予和撤销角色,所以并非总是可以确定哪些账户拥有特定角色。这很重要,因为它允许证明关于系统的某些属性,例如管理账户是多重签名或 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));
}
访问控制对于防止未经授权访问关键功能至关重要。这些功能可用于铸造代币、冻结转账或执行完全更改智能合约逻辑的升级。虽然 Ownable
和 AccessControl
可以防止未经授权的访问,但它们无法解决行为不端的管理员攻击自己的系统以损害其用户的问题。
这就是 TimelockControler
要解决的问题。
TimelockControler
是一个由提议者和执行者管理的代理。当设置为智能合约的所有者/管理员/控制器时,它确保提议者订购的任何维护操作都会受到延迟。此延迟通过让智能合约的用户有时间审查维护操作并在他们认为符合自身最佳利益时退出系统来保护他们。
TimelockControler
默认情况下,部署 TimelockControler
的地址获得对时间锁的管理权限。此角色授予分配提议者、执行者和其他管理员的权利。
配置 TimelockControler
的第一步是至少分配一个提议者和一个执行者。这些可以在构造期间或稍后由任何具有管理员角色的人分配。这些角色不是互斥的,这意味着一个账户可以同时拥有这两个角色。
角色使用 AccessControl
接口进行管理,每个角色的 bytes32
值都可以通过 ADMIN_ROLE
、PROPOSER_ROLE
和 EXECUTOR_ROLE
常量访问。
在 AccessControl
之上还有一个附加功能:将提议者或执行者角色授予 address(0)
会向任何人开放访问权限。此功能虽然可能对测试有用,并且在某些情况下对执行者角色有用,但很危险,应谨慎使用。
此时,在分配提议者和执行者后,时间锁可以执行操作。
可选的下一步是部署者放弃其管理权限并让时间锁自行管理。如果部署者决定这样做,则所有进一步的维护,包括分配新的提议者/调度程序或更改时间锁持续时间,都必须遵循时间锁工作流程。这会将时间锁的治理链接到连接到时间锁的合约的治理,并强制执行时间锁维护操作的延迟。
如果部署者放弃管理权限而支持时间锁本身,则分配新的提议者或执行者将需要时间锁定的操作。这意味着,如果负责这两个角色中任何一个的帐户变得不可用,则整个合约(以及它控制的任何合约)将无限期地锁定。 |
在分配提议者和执行者角色并由时间锁负责其自身管理的情况下,你现在可以将任何合约的所有权/控制权转移到时间锁。
建议的配置是将这两个角色授予安全的治理合约,例如 DAO 或多重签名,并另外将执行者角色授予由负责协助维护操作的人员持有的几个 EOA。这些钱包无法接管时间锁的控制权,但它们可以帮助简化工作流程。 |
由 TimelockControler
执行的操作不受固定延迟的约束,而是受最小延迟的约束。一些重大更新可能需要更长的延迟。例如,如果仅几天的延迟可能足以让用户审计铸造操作,那么在安排智能合约升级时,使用几周甚至几个月的延迟是有意义的。
最小延迟(可以通过 getMinDelay
方法访问)可以通过调用 updateDelay
函数来更新。请记住,只有时间锁本身才能访问此函数,这意味着此维护操作必须通过时间锁本身。
- 原文链接: docs.openzeppelin.com/co...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!