Access
Access control—that is, "who is allowed to do this thing"—is incredibly important in the world of smart contracts. The access control of your contract may govern who can mint tokens, vote on proposals, freeze transfers, and many other things. It is therefore critical to understand how you implement it, lest someone else steals your whole system.
Ownership and Ownable
The most common and basic form of access control is the concept of ownership: there’s an account that is the owner
of a contract and can do administrative tasks on it.
This approach is perfectly reasonable for contracts that have a single administrative user.
OpenZeppelin Contracts for Cairo provides OwnableComponent for implementing ownership in your contracts.
Usage
Integrating this component into a contract first requires assigning an owner.
The implementing contract’s constructor should set the initial owner by passing the owner’s address to Ownable’s
initializer
like this:
#[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);
}
(...)
}
To restrict a function’s access to the owner only, add in the assert_only_owner
method:
#[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();
(...)
}
}
Interface
This is the full interface of the OwnableMixinImpl
implementation:
#[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 also lets you:
-
transfer_ownership
from the owner account to a new one, and -
renounce_ownership
for the owner to relinquish this administrative privilege, a common pattern after an initial stage with centralized administration is over.
Removing the owner altogether will mean that administrative tasks that are protected by assert_only_owner
will no longer be callable!
|
Two step transfer
The component also offers a more robust way of transferring ownership via the
OwnableTwoStepImpl implementation. A two step transfer mechanism helps
to prevent unintended and irreversible owner transfers. Simply replace the OwnableMixinImpl
with its respective two step variant:
#[abi(embed_v0)]
impl OwnableTwoStepMixinImpl = OwnableComponent::OwnableTwoStepMixinImpl<ContractState>;
Interface
This is the full interface of the two step OwnableTwoStepMixinImpl
implementation:
#[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();
}
Role-Based AccessControl
While the simplicity of ownership can be useful for simple systems or quick prototyping, different levels of authorization are often needed. You may want for an account to have permission to ban users from a system, but not create new tokens. Role-Based Access Control (RBAC) offers flexibility in this regard.
In essence, we will be defining multiple roles, each allowed to perform different sets of actions.
An account may have, for example, 'moderator', 'minter' or 'admin' roles, which you will then check for
instead of simply using assert_only_owner
. This check can be enforced through assert_only_role
.
Separately, you will be able to define rules for how accounts can be granted a role, have it revoked, and more.
Most software uses access control systems that are role-based: some users are regular users, some may be supervisors or managers, and a few will often have administrative privileges.
Usage
For each role that you want to define, you will create a new role identifier that is used to grant, revoke, and check if an account has that role. See Creating role identifiers for information on creating identifiers.
Here’s a simple example of implementing AccessControl on a portion of an ERC20 token contract which defines and sets a 'minter' role:
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};
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
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
// AccessControl-related initialization
self.accesscontrol.initializer();
self.accesscontrol._grant_role(MINTER_ROLE, minter);
}
/// This function can only be called by a minter.
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(MINTER_ROLE);
self.erc20.mint(recipient, amount);
}
}
Make sure you fully understand how AccessControl works before using it on your system, or copy-pasting the examples from this guide. |
While clear and explicit, this isn’t anything we wouldn’t have been able to achieve with Ownable. Where AccessControl shines the most is in scenarios where granular permissions are required, which can be implemented by defining multiple roles.
Let’s augment our ERC20 token example by also defining a 'burner' role, which lets accounts destroy tokens:
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};
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
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
// AccessControl-related initialization
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.
#[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.
#[external(v0)]
fn burn(ref self: ContractState, account: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(BURNER_ROLE);
self.erc20.burn(account, amount);
}
}
So clean! By splitting concerns this way, more granular levels of permission may be implemented than were possible with the simpler ownership approach to access control. Limiting what each component of a system is able to do is known as the principle of least privilege, and is a good security practice. Note that each account may still have more than one role, if so desired.
Granting and revoking roles
The ERC20 token example above uses _grant_role
,
an internal
function that is useful when programmatically assigning
roles (such as during construction). But what if we later want to grant the 'minter' role to additional accounts?
By default, accounts with a role cannot grant it or revoke it from other accounts: all having a role does is making
the assert_only_role
check pass. To grant and revoke roles dynamically, you will need help from the role’s admin.
Every role has an associated admin role, which grants permission to call the
grant_role
and
revoke_role
functions.
A role can be granted or revoked by using these if the calling account has the corresponding admin role.
Multiple roles may have the same admin role to make management easier.
A role’s admin can even be the same role itself, which would cause accounts with that role to be able
to also grant and revoke it.
This mechanism can be used to create complex permissioning structures resembling organizational charts, but it also
provides an easy way to manage simpler applications. AccessControl
includes a special role with the role identifier
of 0
, called DEFAULT_ADMIN_ROLE
, which acts as the default admin role for all roles.
An account with this role will be able to manage any other role, unless
set_role_admin
is used to select a new admin role.
Let’s take a look at the ERC20 token example, this time taking advantage of the default admin role:
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};
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
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);
// AccessControl-related initialization
self.accesscontrol.initializer();
self.accesscontrol._grant_role(DEFAULT_ADMIN_ROLE, admin);
}
/// This function can only be called by a 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.
#[external(v0)]
fn burn(ref self: ContractState, account: ContractAddress, amount: u256) {
self.accesscontrol.assert_only_role(BURNER_ROLE);
self.erc20.burn(account, amount);
}
}
The grant_role and revoke_role functions are automatically exposed as external functions
from the AccessControlImpl by leveraging the #[abi(embed_v0)] annotation.
|
Note that, unlike the previous examples, no accounts are granted the 'minter' or 'burner' roles.
However, because those roles' admin role is the default admin role, and that role was granted to the 'admin', that
same account can call grant_role
to give minting or burning permission, and revoke_role
to remove it.
Dynamic role allocation is often a desirable property, for example in systems where trust in a participant may vary over time. It can also be used to support use cases such as KYC, where the list of role-bearers may not be known up-front, or may be prohibitively expensive to include in a single transaction.
Creating role identifiers
In the Solidity implementation of AccessControl, contracts generally refer to the keccak256 hash of a role as the role identifier.
For example:
bytes32 public constant SOME_ROLE = keccak256("SOME_ROLE")
These identifiers take up 32 bytes (256 bits).
Cairo field elements (felt252
) store a maximum of 252 bits.
With this discrepancy, this library maintains an agnostic stance on how contracts should create identifiers.
Some ideas to consider:
-
Use sn_keccak instead.
-
Use Cairo friendly hashing algorithms like Poseidon, which are implemented in the Cairo corelib.
The selector! macro can be used to compute sn_keccak in Cairo.
|
Interface
This is the full interface of the AccessControlMixinImpl
implementation:
#[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
also lets you renounce_role
from the calling account.
The method expects an account as input as an extra security measure, to ensure you are
not renouncing a role from an unintended account.