访问控制

访问控制——即“谁被允许做这件事”——在智能合约的世界中非常重要。 合约的访问控制可以决定谁可以铸造 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_rolerevoke_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_rolerevoke_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。 该方法需要一个账户作为输入作为额外的安全措施,以确保你 不会从意外的账户放弃角色。