Adding and using user identities

Applications often require role-based permissions to control the operations different users can perform.

To illustrate how to create and switch between user identities, this tutorial creates a simple program that displays a different greeting for users who are assigned to different roles.

In this example, there are three named roles—owner, admin, and authorized.

  • Users who are assigned an admin role see a greeting that displays You have a role with administrative privileges.

  • Users who are assigned an authorized role see a greeting that displays Would you like to play a game?.

  • Users who are not assigned one of these roles see a greeting that displays Nice to meet you!.

In addition, only the user identity that initialized the canister is assigned the owner role and only the owner and admin roles can assign roles to other users.

At a high-level, each user has a public/private key pair. The public key combines with the canister identifier the user accesses forms a security principal that can then be used as a message caller to authenticate function calls made to the canister running on the Internet Computer. The following diagram provides a simplified view of how user identities authenticate message callers.

principal identities

Before you begin

Before starting the tutorial, verify the following:

  • You have downloaded and installed the DFINITY Canister SDK package as described in Download and install.

  • You have installed the Visual Studio Code plugin for Motoko as described in Install the language editor plug-in if you are using Visual Studio Code as your IDE.

  • You have stopped any Internet Computer network processes running on the local computer.

Create a new project

To create a new project directory for testing access control and switching user identities:

  1. Open a terminal shell on your local computer, if you don’t already have one open.

  2. Change to the folder you are using for your Internet Computer sample projects.

  3. Create a new project by running the following command:

    dfx new access_hello
  4. Change to your project directory by running the following command:

    cd access_hello

Modify the default program

For this tutorial, you are going to replace the template source code file with a program that has functions for assigning and retrieving roles.

To modify the default program:

  1. Open the src/access_hello/main.mo file in a text editor and delete the existing content.

  2. Copy and paste the following sample code into the file:

    // Import base modules
    import AssocList "mo:base/AssocList";
    import Error "mo:base/Error";
    import List "mo:base/List";
    import Prim "mo:prim";
    
    actor {
    
        // Establish role-based greetings to display
        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!";
            }
        };
    
        // Define custom types
        public type Role = {
            #owner;
            #admin;
            #authorized;
        };
    
        public type Permission = {
            #assign_role;
            #lowest;
        };
    
        private let initializer : Principal = Prim.caller();
    
        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);
            }
        };
    
        // 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;
            }
        };
    
        // Reject unauthorized user identities
        func require_permission(pal: Principal, perm: Permission) : async () {
            if ( has_permission(pal, perm) == false ) {
                throw Error.reject( "unauthorized" );
            }
        };
    
        // 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;
        };
    };

    Let’s take a look at a few key elements of this program:

    • You might notice that the greet function is a variation on the greet function you have seen in previous tutorials.

      In this program, however, the greet function uses a message caller to determine the permissions that should be applied and, based on the permissions associated with the caller, which greeting to display.

    • The program defines two custom types—one for Roles and one for Permissions.

    • The assign_roles function enables the message aller to assign a role to the principal associated with an identity.

    • The callerPrincipal function enables you to return the principal associated with an identity.

    • The my_role function enables you to return the role that is associated with an identity.

  3. Save your changes and close the main.mo file to continue.

Start the local network

Before you can build the access_hello project, you need to connect to the Internet Computer network either running locally in your development environment or running remotely on a sub-network that you can access.

To start the network locally:

  1. Open a new terminal window or tab on your local computer and navigate to your project directory.

    For example, you can do either of the following if running Terminal on macOS:

    • Click Shell, then select New Tab to open a new terminal in your current working directory.

    • Click Shell and select New Window, then run cd ~/ic-projects/access_hello in the new terminal if your access_hello project is in the ic-projects working folder.

    You should now have two terminals open with your project directory as your current working directory.

  2. Start the Internet Computer network on your local computer by running the following command:

    dfx start

    After you start the local network, the terminal displays messages about network operations.

  3. Leave the terminal that displays network operations open and switch your focus to your original terminal where you created your project.

Register canister identifiers

After you connect to the Internet Computer network running locally in your development environment, you can register with the network to generate unique canister identifiers for your project.

To register canister identifiers for the local network:

  1. Check that you are still in your project directory, if needed.

  2. Register a unique canister identifier for the project by running the following command:

    dfx canister create access_hello

    The first time you run this command, it creates a default user identity in the $HOME/.config/dfx/identity/ directory.

    Creating the "default" identity.
      - migrating key from /Users/lisagunn/.dfinity/identity/creds.pem to /Users/lisagunn/.config/dfx/identity/default/identity.pem
    Created the "default" identity.
    Creating canister "access_hello"...
    "access_hello" canister created with canister id: "75hes-oqbaa-aaaaa-aaaaa-aaaaa-aaaaa-aaaaa-q"

    After creating the identity.pem key file for the default user identity, the command displays the network-specific canister identifier for the access_hello main program.

Build and deploy the program

You are now ready to compile the program into a WebAssembly module and deploy it on your local network.

Because this tutorial illustrates running the Internet Computer locally, the canister identifier is only valid on the local network. To deploy canisters on a remote network, you must connect to that network using the --network command-line option and a specific network name or address to register identifiers on that network.

To build and deploy the program:

  1. Check that you are still in your project directory, if needed.

  2. Build the executable WebAssembly module by running the following command:

    dfx build access_hello

    The command displays output similar to the following:

    Building canisters...
  3. Deploy your access_hello project on the local network by running the following command:

    dfx canister install access_hello

    The command output displays output similar to the following:

    Installing code for canister access_hello, with canister_id 75hes-oqbaa-aaaaa-aaaaa-aaaaa-aaaaa-aaaaa-q
  4. Check the principal for the default user identity by running the following command:

    dfx canister call access_hello callerPrincipal

    The command displays output similar to the following:

    (principal "nnuac-d26ja-o4tcm-mcr4i-qxgon-fkuqx-tshde-74bb4-6fead-hdnd6-5ae")
  5. Check the role associated with the default user identity by running the following command:

    dfx canister call access_hello my_role

    The command displays output similar to the following:

    (opt variant { owner })

Create a new user identity

To begin testing the access controls in our program, you need to create some new user identities and assign those users to different roles.

To create a new user identity:

  1. Check that you are still in your project directory, if needed.

  2. Create a new administrative user identity by running the following command:

    dfx identity new ic_admin

    The command displays output similar to the following:

    Creating identity: "ic_admin".
    Created identity: "ic_admin".
  3. Call the my_role function to see that your new user identity has not been assigned to any role.

    dfx --identity ic_admin canister call access_hello my_role

    The command displays output similar to the following:

    (null)
  4. Get the principal for the new ic_admin user identity by running the following command:

    dfx --identity ic_admin canister call access_hello callerPrincipal

    The command displays output similar to the following:

    (principal "kenjo-txsrk-ctozi-gnls4-v4sf4-ndupc-wmrji-nuemy-7hp2y-kwkre-tae")
  5. Assign this principal the admin role by running a command similar to the following using Candid syntax:

    dfx canister call access_hello assign_role '((principal "kenjo-txsrk-ctozi-gnls4-v4sf4-ndupc-wmrji-nuemy-7hp2y-kwkre-tae"),opt variant{admin})'
    Be sure to replace the principal hash with the one returned by the dfx identity callerPrincipal command.

    Optionally, you can rerun the command to call the my_role function to verify the role assignment.

    dfx --identity ic_admin canister call access_hello my_role

    The command displays output similar to the following:

    (opt variant { admin })
  6. Call the greet function using the ic_admin user identity that you just assigned the admin role by running the following command:

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

    The command displays output similar to the following:

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

Add an authorized user identity

At this point, you have a default user identity with the owner role and an ic_admin user identity with the admin role. Let’s add another user identity and assign it to the authorized role. For this example, however, we’ll use an environment variable to store the user’s principal.

To add a new authorized user identity:

  1. Check that you are still in your project directory, if needed.

  2. Create a new authorized user identity by running the following command:

    dfx identity new alice_auth

    The command displays output similar to the following:

    Creating identity: "alice_auth".
    Created identity: "alice_auth".
  3. Store the principal for the new user in an environment variable by running the following command:

    ALICE_ID=$(dfx --identity alice_auth canister call access_hello callerPrincipal | sed 's/[\\(\\)]//g')

    You can verify the principal stored by running the following command:

    echo $ALICE_ID

    The command displays output similar to the following:

    principal "ixskg-luu4i-jxn6i-ie5v4-ynykj-6d2sz-l6ctg-r6524-yxoeb-taimb-rqe"

    Optionally, you can call the my_role function to verify that the principal role assignment is null.

  4. Use the ic_admin identity to assign the authorized role to alice_auth by running the following command:

    dfx --identity ic_admin canister call access_hello assign_role "($ALICE_ID, opt variant{authorized})"
  5. Call the my_role function to verify the role assignment.

    dfx --identity alice_auth canister call access_hello my_role

    The command displays output similar to the following:

    (opt variant { authorized })
  6. Call the greet function using the alice_auth user identity that you just assigned the authorized role by running the following command:

    dfx --identity alice_auth canister call access_hello greet "Alice"

    The command displays output similar to the following:

    (
      "Welcome, Alice. You have an authorized account. Would you like to play a game?",
    )

Add an unauthorized user identity

You have now seen a simple example of creating users with specific roles and permissions. The next step is to create a user identity that is not assigned to a role or given any special permissions.

To add an unauthorized user identity:

  1. Check that you are still in your project directory, if needed.

  2. Create a new user identity by running the following command:

    dfx identity new bob_standard

    The command displays output similar to the following:

    Creating identity: "bob_standard".
    Created identity: "bob_standard".
  3. Call the my_role function to see that your new user identity has not been assigned to any role.

    dfx --identity bob_standard canister call access_hello my_role

    The command displays output similar to the following:

    (null)
  4. Store the principal for the new user in an environment variable by running the following command:

    BOB_ID=$(dfx --identity bob_standard canister call access_hello callerPrincipal | sed 's/[\\(\\)]//g')
  5. Attempt to use the bob_standard identity to assign a role.

    dfx --identity bob_standard canister call access_hello assign_role "($BOB_ID, opt variant{authorized})"

    This command returns an unauthorized error.

  6. Attempt to use the default user identity to assign bob_standard the owner role by running the following command:

    dfx canister call access_hello assign_role "($BOB_ID, opt variant{owner})"

    This command fails because users cannot be assigned the owner role.

  7. Call the greet function using the bob_standard user identity by running the following command:

    dfx --identity bob_standard canister call access_hello greet "Bob"

    The command displays output similar to the following:

    ("Greetings, Bob. Nice to meet you!")

Set the user identity for multiple commands

So far, you have seen how to create and switch between user identities for individual commands. You can also specify a user identity you want to use, then run multiple commands in the context of that user identity.

To run multiple commands under one user identity:

  1. Check that you are still in your project directory, if needed.

  2. List the user identities currently available by running the following command:

    dfx identity list

    The command displays output similar to the following with an asterisk indicating the currently active user identity.

    alice_auth
    bob_standard
    default *
    ic_admin

    In this example, the default user identity is used unless you explicitly select a different identity.

  3. Select a new user identity from the list and make it the active user context by running a command similar to the following:

    dfx identity use ic_admin

    + The command displays output similar to the following:

    Using identity: "ic_admin".

    If you rerun the dfx identity list command, the ic_admin user identity displays an asterisk to indicate it is the currently active user context.

    You can now run commands using the selected user identity without specifying --identity on the command-line.

Stop the local network

After you finish experimenting with the program and using identities, you can stop the local Internet Computer network so that it doesn’t continue running in the background.

To stop the local network:

  1. In the terminal that displays network operations, press Control-C to interrupt the local network process.

  2. Stop the Internet Computer network by running the following command:

    dfx stop