Skip to main content

Access control

Intermediate
Rust
Tutorial

Overview

To demonstrate access control using a Rust backend canister, you'll explore access control in the context of NFTs. NFTs (non-fungible tokens) are unique tokens with arbitrary metadata, usually an image of some kind, to form the digital equivalent of trading cards.

The current NFT standards on ICP are the ICRC-7 and ICRC-37 standards, which define parameters for NFTs and the transfer of NFTs on behalf of another use. However, for the purposes of this example, you will use the older NFT standard DIP-721, as it demonstrates multiple levels of access control using a Rust example. You can see a quick introduction on YouTube.

DIP-721 specifies three levels of access control:

  • Owner: This person owns an NFT. They can transfer the NFT, add/remove operators, or burn the NFT.
  • Operator: A type of delegated owner. The operator does not own the NFT, but can do the same actions an owner can do.
  • Custodian: Creator of the NFT collection/canister. They can do anything (transfer, add/remove operators, burn, and even un-burn) to NFTs, but also mint new ones or change the symbol or description of the collection.

The NFT example canister keeps access control very simple:

  • For every level of control, a separate list (or set) of principals is kept.
  • Those three levels are then manually checked every single time someone attempts to do something for which they require authorization.
  • If a user is not authorized to call a certain function, an error is returned.

Prerequisites

Before getting started, assure you have set up your developer environment according to the instructions in the developer environment guide.

Creating the project

Start by cloning the Github repo for the project's files and navigate into the directory with the commands:

git clone https://github.com/dfinity/examples
cd examples/rust/dip721-nft-container

Let's review the src/lib.rs file which defines the permissions in the canister:

#![allow(clippy::collapsible_else_if)]

#[macro_use]
extern crate ic_cdk_macros;
#[macro_use]
extern crate serde;

use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::iter::FromIterator;
use std::mem;
use std::num::TryFromIntError;
use std::result::Result as StdResult;

use candid::{CandidType, Encode, Principal};
use ic_cdk::{
api::{self, call},
export::candid,
storage,
};
use ic_certified_map::Hash;
use include_base64::include_base64;

mod http;

const MGMT: Principal = Principal::from_slice(&[]);

thread_local! {
static STATE: RefCell<State> = RefCell::default();
}

#[derive(CandidType, Deserialize)]
struct StableState {
state: State,
hashes: Vec<(String, Hash)>,
}

#[pre_upgrade]
fn pre_upgrade() {
let state = STATE.with(|state| mem::take(&mut *state.borrow_mut()));
let hashes = http::HASHES.with(|hashes| mem::take(&mut *hashes.borrow_mut()));
let hashes = hashes.iter().map(|(k, v)| (k.clone(), *v)).collect();
let stable_state = StableState { state, hashes };
storage::stable_save((stable_state,)).unwrap();
}
#[post_upgrade]
fn post_upgrade() {
let (StableState { state, hashes },) = storage::stable_restore().unwrap();
STATE.with(|state0| *state0.borrow_mut() = state);
let hashes = hashes.into_iter().collect();
http::HASHES.with(|hashes0| *hashes0.borrow_mut() = hashes);
}

#[derive(CandidType, Deserialize)]
struct InitArgs {
custodians: Option<HashSet<Principal>>,
logo: Option<LogoResult>,
name: String,
symbol: String,
}

#[init]
fn init(args: InitArgs) {
STATE.with(|state| {
let mut state = state.borrow_mut();
state.custodians = args
.custodians
.unwrap_or_else(|| HashSet::from_iter([api::caller()]));
state.name = args.name;
state.symbol = args.symbol;
state.logo = args.logo;
});
}

#[derive(CandidType, Deserialize)]
enum Error {
Unauthorized,
InvalidTokenId,
ZeroAddress,
Other,
}

impl From<TryFromIntError> for Error {
fn from(_: TryFromIntError) -> Self {
Self::InvalidTokenId
}
}

type Result<T = u128, E = Error> = StdResult<T, E>;

// --------------
// base interface
// --------------

#[query(name = "balanceOfDip721")]
fn balance_of(user: Principal) -> u64 {
STATE.with(|state| {
state
.borrow()
.nfts
.iter()
.filter(|n| n.owner == user)
.count() as u64
})
}

#[query(name = "ownerOfDip721")]
fn owner_of(token_id: u64) -> Result<Principal> {
STATE.with(|state| {
let owner = state
.borrow()
.nfts
.get(usize::try_from(token_id)?)
.ok_or(Error::InvalidTokenId)?
.owner;
Ok(owner)
})
}

#[update(name = "transferFromDip721")]
fn transfer_from(from: Principal, to: Principal, token_id: u64) -> Result {
STATE.with(|state| {
let mut state = state.borrow_mut();
let state = &mut *state;
let nft = state
.nfts
.get_mut(usize::try_from(token_id)?)
.ok_or(Error::InvalidTokenId)?;
let caller = api::caller();
if nft.owner != caller
&& nft.approved != Some(caller)
&& !state
.operators
.get(&from)
.map(|s| s.contains(&caller))
.unwrap_or(false)
&& !state.custodians.contains(&caller)
{
Err(Error::Unauthorized)
} else if nft.owner != from {
Err(Error::Other)
} else {
nft.approved = None;
nft.owner = to;
Ok(state.next_txid())
}
})
}

#[update(name = "safeTransferFromDip721")]
fn safe_transfer_from(from: Principal, to: Principal, token_id: u64) -> Result {
if to == MGMT {
Err(Error::ZeroAddress)
} else {
transfer_from(from, to, token_id)
}
}

#[query(name = "supportedInterfacesDip721")]
fn supported_interfaces() -> &'static [InterfaceId] {
&[
InterfaceId::TransferNotification,
// InterfaceId::Approval, // Psychedelic/DIP721#5
InterfaceId::Burn,
InterfaceId::Mint,
]
}

#[derive(CandidType, Deserialize, Clone)]
struct LogoResult {
logo_type: Cow<'static, str>,
data: Cow<'static, str>,
}

#[export_name = "canister_query logoDip721"]
fn logo() /* -> &'static LogoResult */
{
ic_cdk::setup();
STATE.with(|state| call::reply((state.borrow().logo.as_ref().unwrap_or(&DEFAULT_LOGO),)))
}

#[query(name = "nameDip721")]
fn name() -> String {
STATE.with(|state| state.borrow().name.clone())
}

#[query(name = "symbolDip721")]
fn symbol() -> String {
STATE.with(|state| state.borrow().symbol.clone())
}

const DEFAULT_LOGO: LogoResult = LogoResult {
data: Cow::Borrowed(include_base64!("logo.png")),
logo_type: Cow::Borrowed("image/png"),
};

#[query(name = "totalSupplyDip721")]
fn total_supply() -> u64 {
STATE.with(|state| state.borrow().nfts.len() as u64)
}

#[export_name = "canister_query getMetadataDip721"]
fn get_metadata(/* token_id: u64 */) /* -> Result<&'static MetadataDesc> */
{
ic_cdk::setup();
let token_id = call::arg_data::<(u64,)>().0;
let res: Result<()> = STATE.with(|state| {
let state = state.borrow();
let metadata = &state
.nfts
.get(usize::try_from(token_id)?)
.ok_or(Error::InvalidTokenId)?
.metadata;
call::reply((Ok::<_, Error>(metadata),));
Ok(())
});
if let Err(e) = res {
call::reply((Err::<MetadataDesc, _>(e),));
}
}

#[derive(CandidType)]
struct ExtendedMetadataResult<'a> {
metadata_desc: MetadataDescRef<'a>,
token_id: u64,
}

#[export_name = "canister_update getMetadataForUserDip721"]
fn get_metadata_for_user(/* user: Principal */) /* -> Vec<ExtendedMetadataResult> */
{
ic_cdk::setup();
let user = call::arg_data::<(Principal,)>().0;
STATE.with(|state| {
let state = state.borrow();
let metadata: Vec<_> = state
.nfts
.iter()
.filter(|n| n.owner == user)
.map(|n| ExtendedMetadataResult {
metadata_desc: &n.metadata,
token_id: n.id,
})
.collect();
call::reply((metadata,));
});
}

// ----------------------
// notification interface
// ----------------------

#[update(name = "transferFromNotifyDip721")]
fn transfer_from_notify(from: Principal, to: Principal, token_id: u64, data: Vec<u8>) -> Result {
let res = transfer_from(from, to, token_id)?;
if let Ok(arg) = Encode!(&api::caller(), &from, &token_id, &data) {
// Using call_raw ensures you don't need to await the future for the call to be executed.
// Calling an arbitrary function like this means that a malicious recipient could call
// transferFromNotifyDip721 in their onDIP721Received function, resulting in an infinite loop.
// This will trap eventually, but the transfer will have already been completed and the state-change persisted.
// That means the original transfer must reply before that happens, or the caller will be
// convinced that the transfer failed when it actually succeeded. So you don't await the call,
// so that you'll reply immediately regardless of how long the notification call takes.
let _ = api::call::call_raw(to, "onDIP721Received", arg, 0);
}
Ok(res)
}

#[update(name = "safeTransferFromNotifyDip721")]
fn safe_transfer_from_notify(
from: Principal,
to: Principal,
token_id: u64,
data: Vec<u8>,
) -> Result {
if to == MGMT {
Err(Error::ZeroAddress)
} else {
transfer_from_notify(from, to, token_id, data)
}
}

// ------------------
// approval interface
// ------------------

#[update(name = "approveDip721")]
fn approve(user: Principal, token_id: u64) -> Result {
STATE.with(|state| {
let mut state = state.borrow_mut();
let state = &mut *state;
let caller = api::caller();
let nft = state
.nfts
.get_mut(usize::try_from(token_id)?)
.ok_or(Error::InvalidTokenId)?;
if nft.owner != caller
&& nft.approved != Some(caller)
&& !state
.operators
.get(&user)
.map(|s| s.contains(&caller))
.unwrap_or(false)
&& !state.custodians.contains(&caller)
{
Err(Error::Unauthorized)
} else {
nft.approved = Some(user);
Ok(state.next_txid())
}
})
}

#[update(name = "setApprovalForAllDip721")]
fn set_approval_for_all(operator: Principal, is_approved: bool) -> Result {
STATE.with(|state| {
let mut state = state.borrow_mut();
let caller = api::caller();
if operator != caller {
let operators = state.operators.entry(caller).or_default();
if operator == MGMT {
if !is_approved {
operators.clear();
} else {
// cannot enable everyone as an operator
}
} else {
if is_approved {
operators.insert(operator);
} else {
operators.remove(&operator);
}
}
}
Ok(state.next_txid())
})
}

// #[query(name = "getApprovedDip721")] // Psychedelic/DIP721#5
fn _get_approved(token_id: u64) -> Result<Principal> {
STATE.with(|state| {
let approved = state
.borrow()
.nfts
.get(usize::try_from(token_id)?)
.ok_or(Error::InvalidTokenId)?
.approved
.unwrap_or_else(api::caller);
Ok(approved)
})
}

#[query(name = "isApprovedForAllDip721")]
fn is_approved_for_all(operator: Principal) -> bool {
STATE.with(|state| {
state
.borrow()
.operators
.get(&api::caller())
.map(|s| s.contains(&operator))
.unwrap_or(false)
})
}

// --------------
// mint interface
// --------------

#[update(name = "mintDip721")]
fn mint(
to: Principal,
metadata: MetadataDesc,
blob_content: Vec<u8>,
) -> Result<MintResult, ConstrainedError> {
let (txid, tkid) = STATE.with(|state| {
let mut state = state.borrow_mut();
if !state.custodians.contains(&api::caller()) {
return Err(ConstrainedError::Unauthorized);
}
let new_id = state.nfts.len() as u64;
let nft = Nft {
owner: to,
approved: None,
id: new_id,
metadata,
content: blob_content,
};
state.nfts.push(nft);
Ok((state.next_txid(), new_id))
})?;
http::add_hash(tkid);
Ok(MintResult {
id: txid,
token_id: tkid,
})
}

// --------------
// burn interface
// --------------

#[update(name = "burnDip721")]
fn burn(token_id: u64) -> Result {
STATE.with(|state| {
let mut state = state.borrow_mut();
let nft = state
.nfts
.get_mut(usize::try_from(token_id)?)
.ok_or(Error::InvalidTokenId)?;
if nft.owner != api::caller() {
Err(Error::Unauthorized)
} else {
nft.owner = MGMT;
Ok(state.next_txid())
}
})
}

#[derive(CandidType, Deserialize, Default)]
struct State {
nfts: Vec<Nft>,
custodians: HashSet<Principal>,
operators: HashMap<Principal, HashSet<Principal>>, // owner to operators
logo: Option<LogoResult>,
name: String,
symbol: String,
txid: u128,
}

#[derive(CandidType, Deserialize)]
struct Nft {
owner: Principal,
approved: Option<Principal>,
id: u64,
metadata: MetadataDesc,
content: Vec<u8>,
}

type MetadataDesc = Vec<MetadataPart>;
type MetadataDescRef<'a> = &'a [MetadataPart];

#[derive(CandidType, Deserialize)]
struct MetadataPart {
purpose: MetadataPurpose,
key_val_data: HashMap<String, MetadataVal>,
data: Vec<u8>,
}

#[derive(CandidType, Deserialize, PartialEq)]
enum MetadataPurpose {
Preview,
Rendered,
}

#[derive(CandidType, Deserialize)]
struct MintResult {
token_id: u64,
id: u128,
}

#[allow(clippy::enum_variant_names)]
#[derive(CandidType, Deserialize)]
enum MetadataVal {
TextContent(String),
BlobContent(Vec<u8>),
NatContent(u128),
Nat8Content(u8),
Nat16Content(u16),
Nat32Content(u32),
Nat64Content(u64),
}

impl State {
fn next_txid(&mut self) -> u128 {
let txid = self.txid;
self.txid += 1;
txid
}
}

#[derive(CandidType, Deserialize)]
enum InterfaceId {
Approval,
TransactionHistory,
Mint,
Burn,
TransferNotification,
}

#[derive(CandidType, Deserialize)]
enum ConstrainedError {
Unauthorized,
}

#[update]
fn set_name(name: String) -> Result<()> {
STATE.with(|state| {
let mut state = state.borrow_mut();
if state.custodians.contains(&api::caller()) {
state.name = name;
Ok(())
} else {
Err(Error::Unauthorized)
}
})
}

#[update]
fn set_symbol(sym: String) -> Result<()> {
STATE.with(|state| {
let mut state = state.borrow_mut();
if state.custodians.contains(&api::caller()) {
state.symbol = sym;
Ok(())
} else {
Err(Error::Unauthorized)
}
})
}

#[update]
fn set_logo(logo: Option<LogoResult>) -> Result<()> {
STATE.with(|state| {
let mut state = state.borrow_mut();
if state.custodians.contains(&api::caller()) {
state.logo = logo;
Ok(())
} else {
Err(Error::Unauthorized)
}
})
}

#[update]
fn set_custodian(user: Principal, custodian: bool) -> Result<()> {
STATE.with(|state| {
let mut state = state.borrow_mut();
if state.custodians.contains(&api::caller()) {
if custodian {
state.custodians.insert(user);
} else {
state.custodians.remove(&user);
}
Ok(())
} else {
Err(Error::Unauthorized)
}
})
}

#[query]
fn is_custodian(principal: Principal) -> bool {
STATE.with(|state| state.borrow().custodians.contains(&principal))
}

Then, start the local replica before installing the canister:

dfx start --clean --background

Then, install the canister with the following passed argument:

dfx deploy --no-wallet --argument \
"(record {
name = \"Numbers One Through Fifty\";
symbol = \"NOTF\";
logo = opt record {
data = \"$(base64 -i ./logo.png)\";
logo_type = \"image/png\";
};
custodians = opt vec { principal \"$(dfx identity get-principal)\" };
})"

The canister expects a record parameter with the following fields:

  • custodians: A list of users allowed to manage the canister. If unset, it will default to the caller. If you're using dfx, and haven't specified --no-wallet, that's your wallet principal, not your own, so be careful!
  • name: The name of your NFT collection. Required.
  • symbol: A short slug identifying your NFT collection. Required.
  • logo: The logo of your NFT collection, represented as a record with fields data (the base-64 encoded logo) and logo_type (the MIME type of the logo file). If unset, it will default to the Internet Computer logo.

Interacting with the canister

Now you can interact with the canister. Aside from the standard functions, it has five extra functions:

  • set_name, set_symbol, set_logo, and set_custodian: Update the collection information of the corresponding field from when it was initialized.
  • is_custodian: Checks whether the specified user is a custodian.

The canister also supports a certified HTTPS interface; going to /<nft>/<id> will return nft's metadata file #id, with /<nft> returning the first non-preview file.

Remember that query functions are uncertified; the result of functions like ownerOfDip721 can be modified arbitrarily by a single malicious node. If queried information is depended on, for example if someone might send ICP to the owner of a particular NFT to buy it from them, those calls should be performed as update calls instead. You can force an update call by passing the --update flag to dfx or using the Agent::update function in agent-rs.

To test functionality, you can try to mint an NFT.

Due to size limitations on the length of a terminal command, an image- or video-based NFT would be impossible to send via dfx. To that end, there is an experimental minting tool you can use to mint a single-file NFT.

To use this tool, install the minting tool with the command:

cargo install --git https://github.com/dfinity/experimental-minting-tool --locked

As an example, such as to mint the default logo, you would run the following command:

minting-tool local "$(dfx canister id dip721_nft_container)" --owner "$(dfx identity get-principal)" --file ./logo.png --sha2-auto

The output of this command should look like this:

Successfully minted token 0 to x4d3z-ufpaj-lpxs4-v7gmt-v56ze-aub3k-bvifl-y4lsq-soafd-d3i4k-fqe (transaction id 0)

Minting is restricted to anyone authorized with the custodians parameter or the set_custodians function. Since the contents of --file are stored on-chain, it's important to prevent arbitrary users from minting tokens, or they will be able to store arbitrarily-sized data in the contract and exhaust the canister's cycles. Be careful not to upload too much data to the canister yourself, or the contract will no longer be able to be upgraded afterwards.