访问控制
访问控制——即“谁被允许做这件事”——在智能合约的世界中非常重要。 合约的访问控制可以决定谁可以铸造 token、对提案进行投票、冻结转移以及许多其他事情。 因此,理解如何实现访问控制至关重要,否则其他人可能会 窃取你的整个系统。
所有权和 Ownable
最常见和最基本的访问控制形式是所有权的概念:存在一个账户是合约的 owner
,并且可以对其执行管理任务。
对于具有单个管理用户的合约,这种方法是完全合理的。
OpenZeppelin Contracts for Cairo 提供了 OwnableComponent,用于在合约中实现所有权。
用法
将此组件集成到合约中首先需要分配一个所有者。
实现合约的构造函数应通过将所有者的地址传递给 Ownable 的
initializer
来设置初始所有者,如下所示:
#[starknet::contract]
mod MyContract {
use openzeppelin_access::ownable::OwnableComponent;
use starknet::ContractAddress;
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// Ownable Mixin
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl InternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
// Set the initial owner of the contract
// 设置合约的初始所有者
self.ownable.initializer(owner);
}
(...)
}
要将函数的访问权限仅限于所有者,请添加 assert_only_owner
方法:
#[starknet::contract]
mod MyContract {
(...)
#[external(v0)]
fn only_owner_allowed(ref self: ContractState) {
// This function can only be called by the owner
// 此函数只能由所有者调用
self.ownable.assert_only_owner();
(...)
}
}
接口
这是 OwnableMixinImpl
实现的完整接口:
#[starknet::interface]
pub trait OwnableABI {
// IOwnable
fn owner() -> ContractAddress;
fn transfer_ownership(new_owner: ContractAddress);
fn renounce_ownership();
// IOwnableCamelOnly
fn transferOwnership(newOwner: ContractAddress);
fn renounceOwnership();
}
Ownable 还允许你:
-
将
transfer_ownership
从所有者账户转移到新账户,以及 -
renounce_ownership
,让所有者放弃此管理权限,这是一个常见的模式 在集中管理的初始阶段结束后。
完全删除所有者将意味着受 assert_only_owner 保护的管理任务
将不再可调用!
|
两步转移
该组件还提供了一种更强大的方法,通过
OwnableTwoStepImpl 实现转移所有权。两步转移机制有助于
防止意外和不可逆转的所有者转移。只需用其各自的两步变体替换 OwnableMixinImpl
:
#[abi(embed_v0)]
impl OwnableTwoStepMixinImpl = OwnableComponent::OwnableTwoStepMixinImpl<ContractState>;
接口
这是两步 OwnableTwoStepMixinImpl
实现的完整接口:
#[starknet::interface]
pub trait OwnableTwoStepABI {
// IOwnableTwoStep
fn owner() -> ContractAddress;
fn pending_owner() -> ContractAddress;
fn accept_ownership();
fn transfer_ownership(new_owner: ContractAddress);
fn renounce_ownership();
// IOwnableTwoStepCamelOnly
fn pendingOwner() -> ContractAddress;
fn acceptOwnership();
fn transferOwnership(newOwner: ContractAddress);
fn renounceOwnership();
}
基于角色的 AccessControl
虽然所有权的简单性对于简单的系统或快速原型设计可能很有用,但通常需要不同级别的 授权。你可能希望某个账户有权禁止用户访问系统,但不能 创建新的 token。 基于角色的访问控制 (RBAC) 在这方面提供了 灵活性。
本质上,我们将定义多个角色,每个角色都允许执行不同的操作集。
一个账户可能具有例如“moderator”、“minter”或“admin”角色,然后你将检查这些角色
而不是简单地使用 assert_only_owner
。可以通过 assert_only_role
强制执行此检查。
另外,你将能够定义规则,说明如何授予账户角色、撤销角色等等。
大多数软件使用基于角色的访问控制系统:一些用户是普通用户,一些可能是主管 或经理,少数人通常拥有管理权限。
用法
对于要定义的每个角色,你将创建一个新的*角色标识符*,用于授予、撤销和 检查账户是否具有该角色。有关创建标识符的信息,请参见 创建角色标识符。
这是一个在 ERC20 token 合约的一部分上实现 AccessControl 的简单示例,该合约定义 并设置了一个“minter”角色:
const MINTER_ROLE: felt252 = selector!("MINTER_ROLE");
#[starknet::contract]
mod MyContract {
use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_introspection::src5::SRC5Component;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
use super::MINTER_ROLE;
component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
// AccessControl
#[abi(embed_v0)]
impl AccessControlImpl =
AccessControlComponent::AccessControlImpl<ContractState>;
impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
// ERC20
#[abi(embed_v0)]
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
#[abi(embed_v0)]
impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
accesscontrol: AccessControlComponent::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
AccessControlEvent: AccessControlComponent::Event,
#[flat]
SRC5Event: SRC5Component::Event,
#[flat]
ERC20Event: ERC20Component::Event
}
#[constructor]
fn constructor(
ref self: ContractState,
name: ByteArray,
symbol: ByteArray,
initial_supply: u256,
recipient: ContractAddress,
minter: ContractAddress
) {
// ERC20-related initialization
// 与 ERC20 相关的初始化
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
// AccessControl-related initialization
// 与 AccessControl 相关的初始化
self.accesscontrol.initializer();
self.accesscontrol._grant_role(MINTER_ROLE, minter);
}
/// This function can only be called by a minter.
// 此函数只能由 minter 调用。
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(MINTER_ROLE);
self.erc20.mint(recipient, amount);
}
}
在使用 AccessControl 之前,请确保你完全理解它的工作原理 在你的系统上,或从本指南中复制粘贴示例。 |
虽然清晰而明确,但这并不是我们无法通过 Ownable 实现的。 AccessControl 最有价值的地方是在需要精细 权限的场景中,这可以通过定义*多个*角色来实现。
让我们通过定义一个“burner”角色来增强我们的 ERC20 token 示例,该角色允许账户销毁 token:
const MINTER_ROLE: felt252 = selector!("MINTER_ROLE");
const BURNER_ROLE: felt252 = selector!("BURNER_ROLE");
#[starknet::contract]
mod MyContract {
use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_introspection::src5::SRC5Component;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
use super::{MINTER_ROLE, BURNER_ROLE};
component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
// AccessControl
#[abi(embed_v0)]
impl AccessControlImpl =
AccessControlComponent::AccessControlImpl<ContractState>;
impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
// ERC20
#[abi(embed_v0)]
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
#[abi(embed_v0)]
impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
accesscontrol: AccessControlComponent::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
AccessControlEvent: AccessControlComponent::Event,
#[flat]
SRC5Event: SRC5Component::Event,
#[flat]
ERC20Event: ERC20Component::Event
}
#[constructor]
fn constructor(
ref self: ContractState,
name: ByteArray,
symbol: ByteArray,
initial_supply: u256,
recipient: ContractAddress,
minter: ContractAddress,
burner: ContractAddress
) {
// ERC20-related initialization
// 与 ERC20 相关的初始化
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
// AccessControl-related initialization
// 与 AccessControl 相关的初始化
self.accesscontrol.initializer();
self.accesscontrol._grant_role(MINTER_ROLE, minter);
self.accesscontrol._grant_role(BURNER_ROLE, burner);
}
/// This function can only be called by a minter.
// 此函数只能由 minter 调用。
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(MINTER_ROLE);
self.erc20.mint(recipient, amount);
}
/// This function can only be called by a burner.
// 此函数只能由 burner 调用。
#[external(v0)]
fn burn(ref self: ContractState, account: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(BURNER_ROLE);
self.erc20.burn(account, amount);
}
}
太简洁了! 通过以这种方式分离关注点,可以实现比更简单的所有权访问控制方法更精细的权限级别。限制系统每个组件能够执行的操作被称为 最小权限原则,并且是一种良好的 安全实践。请注意,如果需要,每个账户仍然可以拥有多个角色。
授予和撤销角色
上面的 ERC20 token 示例使用 _grant_role
,
这是一个 internal
函数,在以编程方式分配
角色(例如在构造期间)时很有用。但是,如果稍后我们想将“minter”角色授予其他账户怎么办?
默认情况下,拥有角色的账户不能授予该角色或从其他账户撤销该角色:拥有角色所做的只是使
assert_only_role
检查通过。要动态授予和撤销角色,你需要角色的*admin*的帮助。
每个角色都有一个关联的 admin 角色,该角色授予调用
grant_role
和
revoke_role
函数的权限。
如果调用账户具有相应的 admin 角色,则可以使用它们授予或撤销角色。
多个角色可以具有相同的 admin 角色以简化管理。
角色的 admin 甚至可以是角色本身,这将导致具有该角色的账户也能够
授予和撤销该角色。
此机制可用于创建类似于组织结构图的复杂权限结构,但它也
提供了一种管理更简单应用程序的简便方法。 AccessControl
包含一个特殊的角色,其角色标识符
为 0
,称为 DEFAULT_ADMIN_ROLE
,它充当*所有角色的默认 admin 角色*。
除非使用
set_role_admin
选择新的 admin 角色,否则拥有此角色的账户将能够管理任何其他角色。
由于默认情况下它是所有角色的 admin,并且实际上它也是它自己的 admin,因此该角色具有很大的风险。为了降低这种风险,我们提供 AccessControlDefaultAdminRules,这是 AccessControl 的推荐扩展,它为此角色增加了一些强制执行的安全措施:admin 被限制为单个账户,并采用两步转移程序,步骤之间有延迟。
让我们看一下 ERC20 token 示例,这次利用默认的 admin 角色:
const MINTER_ROLE: felt252 = selector!("MINTER_ROLE");
const BURNER_ROLE: felt252 = selector!("BURNER_ROLE");
#[starknet::contract]
mod MyContract {
use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_access::accesscontrol::DEFAULT_ADMIN_ROLE;
use openzeppelin_introspection::src5::SRC5Component;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
use super::{MINTER_ROLE, BURNER_ROLE};
component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
// AccessControl
#[abi(embed_v0)]
impl AccessControlImpl =
AccessControlComponent::AccessControlImpl<ContractState>;
impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;
// SRC5
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
// ERC20
#[abi(embed_v0)]
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
#[abi(embed_v0)]
impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
(...)
#[constructor]
fn constructor(
ref self: ContractState,
name: ByteArray,
symbol: ByteArray,
initial_supply: u256,
recipient: ContractAddress,
admin: ContractAddress
) {
// ERC20-related initialization
// 与 ERC20 相关的初始化
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
// AccessControl-related initialization
// 与 AccessControl 相关的初始化
self.accesscontrol.initializer();
self.accesscontrol._grant_role(DEFAULT_ADMIN_ROLE, admin);
}
/// This function can only be called by a minter.
// 此函数只能由 minter 调用。
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(MINTER_ROLE);
self.erc20.mint(recipient, amount);
}
/// This function can only be called by a burner.
// 此函数只能由 burner 调用。
#[external(v0)]
fn burn(ref self: ContractState, account: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(BURNER_ROLE);
self.erc20.burn(account, amount);
}
}
通过利用 #[abi(embed_v0)] 注解,grant_role 和 revoke_role 函数会自动作为 external 函数从 AccessControlImpl 公开。
|
请注意,与之前的示例不同,没有账户被授予“minter”或“burner”角色。
但是,由于这些角色的 admin 角色是默认 admin 角色,并且该角色已授予“admin”,因此
同一账户可以调用 grant_role
以授予铸造或销毁权限,并调用 revoke_role
以删除它。
动态角色分配通常是一个理想的属性,例如在参与者的信任可能随时间变化的系统中。 它还可以用于支持诸如 KYC 之类的用例, 在这种情况下,角色承担者的列表可能不是预先知道的,或者将其包含在单个交易中可能过于昂贵。
创建角色标识符
在 AccessControl 的 Solidity 实现中,合约通常将 keccak256 哈希 的角色作为角色标识符。
例如:
bytes32 public constant SOME_ROLE = keccak256("SOME_ROLE")
这些标识符占用 32 字节(256 位)。
Cairo 字段元素 (felt252
) 最多存储 252 位。
由于存在这种差异,此库对合约应如何创建标识符保持不可知论的立场。
需要考虑的一些想法:
-
使用 sn_keccak 代替。
-
使用 Cairo 友好的哈希算法,如 Poseidon,该算法在 Cairo corelib 中实现。
selector! 宏可用于在 Cairo 中计算 sn_keccak。
|
接口
这是 AccessControlMixinImpl
实现的完整接口:
#[starknet::interface]
pub trait AccessControlABI {
// IAccessControl
fn has_role(role: felt252, account: ContractAddress) -> bool;
fn get_role_admin(role: felt252) -> felt252;
fn grant_role(role: felt252, account: ContractAddress);
fn revoke_role(role: felt252, account: ContractAddress);
fn renounce_role(role: felt252, account: ContractAddress);
// IAccessControlCamel
fn hasRole(role: felt252, account: ContractAddress) -> bool;
fn getRoleAdmin(role: felt252) -> felt252;
fn grantRole(role: felt252, account: ContractAddress);
fn revokeRole(role: felt252, account: ContractAddress);
fn renounceRole(role: felt252, account: ContractAddress);
// ISRC5
fn supports_interface(interface_id: felt252) -> bool;
}
AccessControl
还允许你从调用账户 renounce_role
。
该方法需要一个账户作为输入作为额外的安全措施,以确保你
不会从意外的账户放弃角色。