访问控制

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

所有权和 Ownable

最常见和最基本的访问控制形式是_所有权_的概念:存在一个帐户是合约的 owner,并且可以在其上执行管理任务。对于具有单个管理用户的合约,此方法是完全合理的。

OpenZeppelin Contracts 提供了 Ownable 用于在你的合约中实现所有权。

Unresolved include directive in modules/ROOT/pages/access-control.adoc - include::api:example$access-control/MyContractOwnable.sol[]

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

Ownable 还允许你:

  • transferOwnership 从所有者帐户转移到新帐户,以及

  • renounceOwnership 用于所有者放弃此管理权限,这在以中心化管理为特征的初始阶段结束后是一种常见模式。

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

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

请注意,合约也可以是另一个合约的所有者!这为使用例如 Gnosis Safe、https://aragon.org[Aragon DAO] 或 创建的完全自定义的合约打开了大门。

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

基于角色的访问控制

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

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

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

使用 AccessControl

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

这是一个在 ERC-20 代币中使用 AccessControl 的简单示例,用于定义“minter”角色,该角色允许具有该角色的帐户创建新代币:

Unresolved include directive in modules/ROOT/pages/access-control.adoc - include::api:example$access-control/AccessControlERC20MintBase.sol[]
在你的系统中使用 AccessControl 或复制粘贴本指南中的示例之前,请确保你完全理解它的工作原理。

虽然清晰且明确,但这并不是我们无法使用 Ownable 实现的。实际上,AccessControl 的优势在于需要细粒度权限的场景,这可以通过定义_多个_角色来实现。

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

Unresolved include directive in modules/ROOT/pages/access-control.adoc - include::api:example$access-control/AccessControlERC20MintOnlyRole.sol[]

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

授予和撤销角色

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

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

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

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

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

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

Unresolved include directive in modules/ROOT/pages/access-control.adoc - include::api:example$access-control/AccessControlERC20MintMissing.sol[]

请注意,与之前的示例不同,没有帐户被授予“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));
}

延迟操作

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

这是 TimelockController 正在解决的问题。

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

使用 TimelockController

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

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

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

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

此时,分配了提议者和执行者,时间锁可以执行操作。

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

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

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

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

最小延迟

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

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

访问管理

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

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

访问控制多重

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

AccessManager

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

  • 角色被授予帐户(地址),采用多对多的方法以实现灵活性。这意味着每个用户可以具有一个或多个角色,并且多个用户可以具有相同的角色。

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

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

AccessManager 函数

使用 AccessManager

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

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

这是一个 ERC-20 代币的简单示例,它定义了一个受 AccessManager 限制的 mint 函数:

Unresolved include directive in modules/ROOT/pages/access-control.adoc - include::api:example$access-control/AccessManagedERC20MintBase.sol[]
在使用 AccessManager 或复制粘贴本指南中的示例之前,请确保你完全理解它的工作原理。

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

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

// Grant the minter role with no execution delay
await manager.grantRole(MINTER, user, 0);

// Allow the minter role to call the function selector
// 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
await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY);

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

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

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

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

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

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

const target = await myToken.getAddress();

// Token's `restricted` functions closed
await manager.setTargetClosed(target, true);

// Token's `restricted` functions open
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);