3.6 Motoko level 3
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:
- dfx v0.17.0 or newer
- dfx v0.16.1 or older
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
dfx start --clean --background
dfx new access_hello
cd access_hello
Remember, by default dfx new
creates a new project in the Motoko language. If you'd like to create a Rust project, use the flag --type=rust
.
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
andPermissions
.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:
Developer Discord community, which is a large chatroom for ICP developers to ask questions, get help, or chat with other developers asynchronously via text chat.
Motoko bootcamp, a week-long crash course to learning all things Motoko.
Weekly developer office hours to ask questions, get clarification, and chat with other developers live via voice chat. This is hosted on our developer Discord group.
Submit your feedback to the ICP Developer feedback board.
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.