Skip to main content

3.6 Motoko level 3

Intermediate
Tutorial

Overview

In the previous module, 3.5: Identities and authentication you covered different forms of identities and authentication on ICP. In this Motoko level, you'll look into caller identification, adding access control with identities, plus other non-identity related functionalities such as pattern matching and periodic tasks.

Principals and caller identification

Motoko's shared functions support a form of caller identification, allowing for the principal associated with the caller of a function to be returned. Recall that a principal is a unique identifier associated with the user or canister. Principals can be used to implement basic access control for an application.

In Motoko, recall that the shared keyword is used to declare a shared function, and that a shared function immediately returns a value known as a future. This future value indicates that an asynchronous value is expected to be returned. A shared function's arguments and return value(s) must also be shared types. Shared functions can declare an optional parameter of type {caller : Principal}. The developer journey covered shared functions in more detail in 2.6: Motoko level 2

To demonstrate this, consider the following function:

shared(msg) func inc() : async () {
// ... msg.caller ...
}

In this basic demonstration, the shared function inc() specifies a msg parameter, a record, and the msg.caller accesses the principal field of msg. Calls to the inc() function do not change. When the call is made, the caller's principal is provided by the system, not the user, so the principal cannot be forged or spoofed maliciously.

Additionally, the caller of an actor class can be called using the same syntax on the actor class declaration, such as:

shared(msg) actor class Counter(init : Nat) {
// ... msg.caller ...
}

To further this example, imagine a scenario where you have the Counter actor that you used in a previous example that you want to restrict access to. You can modify the original example so that only the principal that installed the actor can modify the actor by recording the principal that installed the actor and binding it to an owner variable, then checking that the caller of each method is equal to the owner variable:

shared(msg) actor class Counter(init : Nat) {

let owner = msg.caller;

var count = init;

public shared(msg) func inc() : async () {
assert (owner == msg.caller);
count += 1;
};

public func read() : async Nat {
count
};

public shared(msg) func bump() : async Nat {
assert (owner == msg.caller);
count := 1;
count;
};
}

In this code, the assert (owner == msg.caller) expression causes the functions inc() and bump() to trap if the call is unauthorized, preventing any modification of the count variable while the read() function permits any caller.

Adding access control with identities

Now, let's look at creating a sample dapp that uses role-based permissions to control different operations that users can perform.

In this example, you'll create a simple app that displays a different greeting for users that have been assigned different roles. You'll use three roles: owner, admin, and authorized:

  • Users who have the admin role will receive the greeting, "You have a role with administrative privileges."

  • Users who have the authorized role will receive the greeting, "Are you enjoying your developer journey?".

  • Users who are not assigned a role will receive the greeting, "Nice to meet you!".

Only the principal that initialized the canister is assigned the owner role, and only principals with the owner and admin roles can assign roles to others.

Prerequisites

Before you start, verify that you have set up your developer environment according to the instructions in 0.3: Developer environment setup.

Creating a new project

To get started, create a new project in your working directory. Open a terminal window, navigate into your working directory (developer_journey), then use the commands:

Use dfx new <project_name> to create a new project:

dfx start --clean --background
dfx new access_hello

You will be prompted to select the language that your backend canister will use. Select 'Motoko':

? Select a backend language: ›
❯ Motoko
Rust
TypeScript (Azle)
Python (Kybra)

Then, select a frontend framework for your frontend canister. Select 'No frontend canister':

  ? Select a frontend framework: ›
SvelteKit
React
Vue
Vanilla JS
No JS template
❯ No frontend canister

Lastly, you can include extra features to be added to your project:

  ? Add extra features (space to select, enter to confirm) ›
⬚ Internet Identity
⬚ Bitcoin (Regtest)
⬚ Frontend tests

Then, navigate into the new project directory:

cd access_hello

Creating an owner identity

First, let's create a new identity to use. You'll call this identity 'owner', since it will be the owner of the canister you create:

dfx identity new owner
dfx identity use owner

Now, let's write the code that sets up the simple access control app. Open the src/access_hello_backend/main.mo file in your code editor and replace the existing content with the following pieces of code:

Writing backend code with access control

Start by importing the base modules:

import AssocList "mo:base/AssocList";
import Error "mo:base/Error";
import List "mo:base/List";

Then, declare your shared actor class which defines three role-based greetings to display using an if/else statement:

shared({ caller = initializer }) actor class() {

public shared({ caller }) func greet(name : Text) : async Text {
if (has_permission(caller, #assign_role)) {
return "Hello, " # name # ". You have a role with administrative privileges."
} else if (has_permission(caller, #lowest)) {
return "Welcome, " # name # ". You have an authorized account. Would you like to play a game?";
} else {
return "Greetings, " # name # ". Nice to meet you!";
}
};

Next, define the custom types Role and Permission:

    public type Role = {
#owner;
#admin;
#authorized;
};

public type Permission = {
#assign_role;
#lowest;
};

Then, define stable variables to store a list of principals and their associated role:

    private stable var roles: AssocList.AssocList<Principal, Role> = List.nil();
private stable var role_requests: AssocList.AssocList<Principal, Role> = List.nil();

Create a function that checks if principal a equals principal b:

    func principal_eq(a: Principal, b: Principal): Bool {
return a == b;
};

Define a function to get the principal's current role, first by checking if the principal is the initializer of the canister, then by checking the list that stores principals and their associated role:

    func get_role(pal: Principal) : ?Role {
if (pal == initializer) {
?#owner;
} else {
AssocList.find<Principal, Role>(roles, pal, principal_eq);
}
};

Then, determine if a principal has a role with permissions:

    func has_permission(pal: Principal, perm : Permission) : Bool {
let role = get_role(pal);
switch (role, perm) {
case (?#owner or ?#admin, _) true;
case (?#authorized, #lowest) true;
case (_, _) false;
}
};

Define a function that rejects any unauthorized principals:

    func require_permission(pal: Principal, perm: Permission) : async () {
if ( has_permission(pal, perm) == false ) {
throw Error.reject( "unauthorized" );
}
};

Define a function to assign a new role to a principal:

    public shared({ caller }) func assign_role( assignee: Principal, new_role: ?Role ) : async () {
await require_permission( caller, #assign_role );

switch new_role {
case (?#owner) {
throw Error.reject( "Cannot assign anyone to be the owner" );
};
case (_) {};
};
if (assignee == initializer) {
throw Error.reject( "Cannot assign a role to the canister owner" );
};
roles := AssocList.replace<Principal, Role>(roles, assignee, principal_eq, new_role).0;
role_requests := AssocList.replace<Principal, Role>(role_requests, assignee, principal_eq, null).0;
};

public shared({ caller }) func request_role( role: Role ) : async Principal {
role_requests := AssocList.replace<Principal, Role>(role_requests, caller, principal_eq, ?role).0;
return caller;
};

Return the principal of the message caller/user identity:

    public shared({ caller }) func callerPrincipal() : async Principal {
return caller;
};

Return the role of the message caller/user identity:

    public shared({ caller }) func my_role() : async ?Role {
return get_role(caller);
};

public shared({ caller }) func my_role_request() : async ?Role {
AssocList.find<Principal, Role>(role_requests, caller, principal_eq);
};

public shared({ caller }) func get_role_requests() : async List.List<(Principal,Role)> {
await require_permission( caller, #assign_role );
return role_requests;
};

public shared({ caller }) func get_roles() : async List.List<(Principal,Role)> {
await require_permission( caller, #assign_role );
return roles;
};
};

In total, the code in the src/access_hello_backend/main.mo file should be:

import AssocList "mo:base/AssocList";
import Error "mo:base/Error";
import List "mo:base/List";

shared({ caller = initializer }) actor class() {

public shared({ caller }) func greet(name : Text) : async Text {
if (has_permission(caller, #assign_role)) {
return "Hello, " # name # ". You have a role with administrative privileges."
} else if (has_permission(caller, #lowest)) {
return "Welcome, " # name # ". You have an authorized account. Would you like to play a game?";
} else {
return "Greetings, " # name # ". Nice to meet you!";
}
};

public type Role = {
#owner;
#admin;
#authorized;
};

public type Permission = {
#assign_role;
#lowest;
};

private stable var roles: AssocList.AssocList<Principal, Role> = List.nil();
private stable var role_requests: AssocList.AssocList<Principal, Role> = List.nil();

func principal_eq(a: Principal, b: Principal): Bool {
return a == b;
};

func get_role(pal: Principal) : ?Role {
if (pal == initializer) {
?#owner;
} else {
AssocList.find<Principal, Role>(roles, pal, principal_eq);
}
};

func has_permission(pal: Principal, perm : Permission) : Bool {
let role = get_role(pal);
switch (role, perm) {
case (?#owner or ?#admin, _) true;
case (?#authorized, #lowest) true;
case (_, _) false;
}
};

func require_permission(pal: Principal, perm: Permission) : async () {
if ( has_permission(pal, perm) == false ) {
throw Error.reject( "unauthorized" );
}
};

public shared({ caller }) func assign_role( assignee: Principal, new_role: ?Role ) : async () {
await require_permission( caller, #assign_role );

switch new_role {
case (?#owner) {
throw Error.reject( "Cannot assign anyone to be the owner" );
};
case (_) {};
};
if (assignee == initializer) {
throw Error.reject( "Cannot assign a role to the canister owner" );
};
roles := AssocList.replace<Principal, Role>(roles, assignee, principal_eq, new_role).0;
role_requests := AssocList.replace<Principal, Role>(role_requests, assignee, principal_eq, null).0;
};

public shared({ caller }) func request_role( role: Role ) : async Principal {
role_requests := AssocList.replace<Principal, Role>(role_requests, caller, principal_eq, ?role).0;
return caller;
};

public shared({ caller }) func callerPrincipal() : async Principal {
return caller;
};

public shared({ caller }) func my_role() : async ?Role {
return get_role(caller);
};

public shared({ caller }) func my_role_request() : async ?Role {
AssocList.find<Principal, Role>(role_requests, caller, principal_eq);
};

public shared({ caller }) func get_role_requests() : async List.List<(Principal,Role)> {
await require_permission( caller, #assign_role );
return role_requests;
};

public shared({ caller }) func get_roles() : async List.List<(Principal,Role)> {
await require_permission( caller, #assign_role );
return roles;
};
};

The key components of this app are:

  • A greet function that uses a message caller to determine the permissions that should be applied to a principal, then displays a greeting depending on the permissions associated with the caller.

  • Two custom types: Roles and Permissions.

  • An assign_roles function that enables the message caller to assign a role to a principal.

  • A callerPrincipal function that returns the principal associated with an identity.

  • A my_role function that returns the role associated with an identity.

Save these changes made to the src/access_hello_backend/main.mo file.

Now let's deploy our canister with the command:

dfx deploy

Interacting with the dapp

Now that your canister is deployed, you can interact with it using different identities. First, let's confirm that you're still using the owner identity you created earlier:

dfx identity whoami

This should return:

owner

If the command displays anything else, you can switch back to the owner identity with the same command you used earlier:

dfx identity use owner

Then, get the principal for this identity with the command:

dfx identity get-principal

The command displays output similar to the following:

65fam-cvhbx-ny6xn-p2l44-x3bai-ksvzb-2krf2-2fy3m-d2vde-cnrt6-aqe

Now, let's check this identity's role associated with the canister:

dfx canister call access_hello_backend my_role

The command should return a response indicating that your identity has the owner role:

(opt variant { owner })

Now let's create some new user identities and assign them different roles. First, let's create a new identity that'll have the admin role:

dfx identity new ic_admin

Then, make a call to the canister to see what role this identity currently has:

dfx --identity ic_admin canister call access_hello_backend my_role

This will return the response of (null), since you haven't assigned the identity a role. Now, let's assign it a role, but first you'll need to get it's principal. To do that, set the ic_admin identity as your currently-active identity, then use the dfx identity get-principal command to get the identity's principal:

dfx identity use ic_admin && dfx identity get-principal

The command displays output similar to the following:

Using identity: "ic_admin".
scc3r-hhpnt-264cn-t2ud3-sx74o-5txbl-arwi5-h7c4s-wx7zc-sl54q-dqe

Now, to assign the admin role to the ic_admin identity, switch back to the owner identity, then make a call to the canister:

dfx identity use owner
dfx canister call access_hello_backend assign_role '((principal "scc3r-hhpnt-264cn-t2ud3-sx74o-5txbl-arwi5-h7c4s-wx7zc-sl54q-dqe"),opt variant{admin})'

Be sure to replace the principal with the one returned by the dfx identity get-principal command for the ic_admin identity.

Now, make another call to the canister to verify that the role has been updated for the principal; it should now return admin instead of (null).

dfx --identity ic_admin canister call access_hello_backend my_role

Let's call the greet function for the ic_admin identity to assure that it returns the correct greeting for the role admin:

dfx --identity ic_admin canister call access_hello_backend greet "Internet Computer Admin"

The command displays output similar to the following:

(
"Hello, Internet Computer Admin. You have a role with administrative privileges.",
)

From here, you can repeat the previous steps to create other identities and assign them the admin or authorized role.

Pattern matching

In Motoko, pattern matching is a feature for testing and decomposing structured data into its individual components. With Motoko's pattern matching, data fragments derived from structured data can be bound to names that you define. Patterns resemble the structure of structured data in their syntax, but generally appear in input-direction positions, such as after the case keyword in switch expressions, or after let or var declarations.

For example, consider the following function call:

let name : Text = fullName({ first = "Jane"; mid = "M"; last = "Doe" });

This function call constructs a record containing three fields, then passes it to the function called fullName. The call's result is bounded to the identifier name. The last step that binds the result to the identifier is an example of pattern matching, where name : Text is one of the simplest forms of a pattern. To further demonstrate, the following is an example of the function:

func fullName({ first : Text; mid : Text; last : Text }) : Text {
first # " " # mid # " " # last
};

The input in this callee is an anonymous object, which is then deconstructed into the object's three Text fields that are bound to the identifiers first, mid, and last. These values can be freely used in the block that forms the body of the function.

Pattern matching can also be used to declare literal patterns, which resemble literal constants. Literal patterns can be useful in switch expressions, since in case the first pattern match fails, the next pattern match can be checked, and so forth. For example:

switch ("Adrienne", #female) {
case (name, #female) { name # " is a girl!" };
case (name, #male) { name # " is a boy!" };
case (name, _) { name # ", is a human!" };
}

This switch expression matches the first case clause, since binding to the identifier name cannot fail, and then the variant literal #female is matched by the first case, resulting in the evaluation of "Adrienne is a girl!". The last clause in the switch expression uses the wildcard pattern _, which cannot fail but won't bind to an identifier.

Periodic tasks

Canister smart contracts can be configured to execute automatically on a periodic schedule or after a specified delay. To configure this, there are two ways to schedule canister execution:

  • Timers: A timer is a single-expiration or single periodic canister call with a specified minimum timeout or interval.

  • Heartbeats: A legacy periodic canister invocation with intervals around 1s (the blockchain finalization rate).

Since heartbeats are a legacy feature, new canisters should opt to use timers instead of heartbeats. For that reason, you'll look at timers in this guide.

To support timers, the Motoko Timer.mo library exists. The following is a simple example that creates a periodic reminder for logging a message every new year:

import { print } = "mo:base/Debug";
import { abs } = "mo:base/Int";
import { now } = "mo:base/Time";
import { setTimer; recurringTimer } = "mo:base/Timer";

actor Reminder {

let solarYearSeconds = 356_925_216;

private func remind() : async () {
print("Happy New Year!");
};

ignore setTimer(#seconds (solarYearSeconds - abs(now() / 1_000_000_000) % solarYearSeconds),
func () : async () {
ignore recurringTimer(#seconds solarYearSeconds, remind);
await remind();
});
}

In this code, there is a global timer that issues callbacks from a queue maintained by Motoko's runtime. When lower-level access to the canister global timer is needed, an actor can choose to receive timer expiry messages by declaring the system function named timer. This function takes a single argument and returns a future of unit type (async ()).

The following is an example of a global timer expiration callback that gets called immediately after the canister starts, such as immediately after install, then periodically every twenty seconds thereafter:

system func timer(setGlobalTimer : Nat64 -> ()) : async () {
let next = Nat64.fromIntWrap(Time.now()) + 20_000_000_000;
setGlobalTimer(next);
print("Timer!");
}

Need help?

Did you get stuck somewhere in this tutorial, or feel like you need additional help understanding some of the concepts? The ICP community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out:

Next steps

That'll wrap up level 3 of our developer journey! In the next level, you'll start by taking a look at using the ICP ledger.