Building Permission Systems in Clarity: From Owner to Custom Roles

eriq ferrarieriq ferrari
13 min read

🧩 1. Introduction

In the world of smart contracts, a permission system—also known as access control—defines who is allowed to perform which actions within a contract. Whether you're building a token, a decentralized application, or a DAO, it's crucial to ensure that only trusted accounts can call sensitive functions like minting, upgrading, or pausing the system.

Without proper access control, anyone could potentially exploit your contract, leading to loss of funds, irreversible state changes, or security breaches.

To address this, the Solidity ecosystem widely adopts OpenZeppelin’s libraries, specifically:

  • Ownable: a simple pattern that designates a single owner with exclusive access to certain functions.

  • AccessControl: a more advanced and flexible system for managing multiple roles (e.g., admin, minter, moderator), each with specific permissions.

This tutorial brings these concepts into the Clarity language on the Stacks blockchain. You'll learn how to:

  • Set up an owner with exclusive control over critical functions.

  • Implement functionality to transfer or renounce ownership.

  • Create and manage custom roles, controlled by the owner, to allow fine-grained access to different parts of your contract logic.

Let’s dive in and build a robust permission system in Clarity—taking inspiration from the battle-tested patterns of OpenZeppelin.

🔐 2. Ownership in Smart Contracts: Solidity vs Clarity

In Solidity, one of the most common and essential access control patterns is Ownable, popularized by OpenZeppelin’s Ownable contract. This pattern introduces a single privileged account—usually the contract deployer—called the owner.

The contract includes:

  • An owner state variable

  • A modifier like onlyOwner to restrict access to certain functions

  • Functions to transferOwnership or renounceOwnership

🟫 Translating to Clarity

In Clarity, there's no inheritance or modifiers like in Solidity, so access control must be implemented explicitly. That said, the logic remains simple and robust.

To replicate the Ownable pattern in Clarity, we’ll define:

  1. An owner variable to store the admin principal

  2. A helper function is-owner to check permissions

  3. A transfer-ownership function, callable only by the current owner

  4. A renounce-ownership function, which sets a is-renounced flag to true, locking the contract from further changes

This pattern ensures that only the designated account can execute privileged operations, and it also allows the owner to permanently relinquish control for maximum decentralization or immutability.

⚙️ 3. Implementing the Ownership Pattern in Clarity

Let’s now build a basic ownership system in Clarity, inspired by OpenZeppelin’s Ownable. This will include defining the owner, checking permissions, transferring ownership, and renouncing control of the contract.


📌 Step 1: Define the owner variable

We'll start by declaring a contract variable that stores the principal address of the owner. This is typically initialized at deployment time.

;; tx-sender will be set as first owner of the contract
(define-data-var owner principal tx-sender)

📌 Step 2: Add a helper function is-owner

To avoid repeating ownership checks throughout the contract, it’s good practice to write a dedicated function that returns true if the caller is the current owner.

;; Read-only function to check if the contract caller is the owner.
;; Using `contract-caller` is safer than `tx-sender` as it ensures that
;; only direct calls from the owner's wallet (not via other contracts) are allowed.
(define-read-only (is-owner)
  (is-eq contract-caller (var-get owner))
)

📌 Step 3: Create a transfer-ownership function

The current owner should be able to assign ownership to another principal. To prevent misuse, we’ll require that:

  • Only the current owner can call this function.

  • The contract has not been renounced.

;; Variable to track whether ownership has been renounced.
(define-data-var is-renounced bool false)

;; Public function to transfer ownership.
;; Only the current owner can call this function,
;; and only if ownership has not been renounced.

(define-public (transfer-ownership (address principal))
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (asserts! (not (var-get is-renounced)) ALREADY_RENOUNCED)
    (var-set owner address)
    (print {
        topic: "Transfer Ownership",
        calledBy: contract-caller,
        newOwner: address,
    })
    (ok true)
  )
)

📌 Step 4: Add a renounce-ownership function

In cases where decentralization is a priority, the owner might want to permanently give up control. We’ll implement this with a boolean variable like is-renounced, which disables all future admin actions once set to true.

(define-public (renounce-ownership)
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (asserts! (not (var-get is-renounced)) ALREADY_RENOUNCED)
    (var-set is-renounced true)
    (print {
        topic: "Renounce Ownership",
        calledBy: contract-caller,
    })
    (ok true)
  )
)

📌 Step 5: Simplify Admin Checks with an Enhanced is-owner Function

To keep the contract clean and avoid repeating checks in every admin-restricted function, we can improve the is-owner helper by embedding the is-renounced check inside it. This way, any function that calls is-owner will automatically reject execution if the ownership has been renounced.

This makes access control more declarative and less error-prone across the contract.

;; read-only function to check if the contract caller is the owner
;; the use of contract-caller is safer than tx-sender because allows 
;; only functions calls directly from the owner wallet.
;; if the contract has been renounced it returns false
(define-read-only (is-owner)
    (if (var-get is-renounced) 
      false
      (is-eq contract-caller (var-get owner))
    )
)

Why this matters:
This small change significantly improves the clarity of your permission system. Instead of duplicating logic, every privileged function can simply rely on is-owner, knowing that both ownership and renouncement status are enforced in one place.


📝 Tracking Access Events with print

In the context of access control, it's important to maintain an audit trail to know who performed critical actions like transferring ownership or renouncing control. For this reason, the functions transfer-ownership and renounce-ownership emit a receipt using the print function.

The printed log includes:

  • Topic: The function being called (Transfer Ownership or Renounce Ownership)

  • Contract Caller: The address that initiated the transaction (i.e., the actor performing the action)

  • New Owner (only for transfer-ownership): The address of the new owner if ownership was transferred

This audit trail enhances transparency, security, and accountability, making it easy to track administrative changes in the contract.


🔐 4. Role-Based Access Control in Solidity

In Solidity, Role-Based Access Control (RBAC) is implemented in a robust and flexible way with the help of OpenZeppelin's AccessControl contract. This contract enables developers to manage permissions more granularly by defining multiple roles within the contract and assigning those roles to different accounts.

Key Features in OpenZeppelin’s AccessControl:

  • DEFAULT_ADMIN_ROLE: The default role that can manage other roles. This is often used for the contract owner or an administrator who can assign and revoke other roles.

  • grantRole: This function allows an account to be assigned a specific role.

  • revokeRole: This function is used to remove a role from an account.

  • hasRole: This function checks whether a given account has a specific role.

Using these features, OpenZeppelin provides a flexible and powerful way to manage permissions, with clear distinctions between different types of access.

📚 What We Can Learn for Clarity:

In Clarity, we can implement a similar system by defining roles based on principal => bool mappings. Instead of using built-in Solidity modifiers like onlyOwner, Clarity requires us to manually enforce role checks.

By implementing custom roles, we can replicate the flexibility and security of RBAC, ensuring that only specific accounts can perform actions like transferring ownership, managing users, or executing administrative tasks.

🛠️ 5. Implementing Custom Roles in Clarity

Now let’s explore how we can implement custom roles in Clarity. Unlike Solidity, where roles are handled through libraries like OpenZeppelin, in Clarity, we define roles through mappings and functions that manage these roles manually.

Design Choices:

  1. Principal => Bool Mappings: We will create mappings for each role, where a principal (an account) is mapped to a bool value indicating whether they hold that role.

  2. Functions to Manage Roles:

    • add-[role-name]: Assigns a role to a given principal.

    • remove-[role-name]: Removes a role from a given principal.

    • is-[role-name]: Checks if a principal holds a specific role.

Implementing Hierarchical Roles:

To introduce hierarchy, we will define three levels of roles with different capabilities:

  • Admin: The highest role, capable of creating and removing moderators and users.

  • Moderator: Can manage users (add or remove them).

  • User: The most basic role, with the ability to remove themselves only (i.e., renounce their role).

Let’s break down the functionality for each role:


1. Admin Role:

The admin has the most permissions and can manage both moderators and users. An admin can assign and remove the moderator and user roles.


2. Moderator Role:

Moderators can add or remove user roles. However, they cannot assign themselves or others the admin role. This is a lower level of authority compared to the admin, and it limits their control to only users.


3. User Role:

Users have the least authority and can only revoke their own access by renouncing their user role. They cannot manage other roles and are entirely dependent on the admin or moderator for further changes to their role.

📝 6. Step-by-Step Guide: Implementing Moderators and Users in Clarity

In this section, we will walk through the step-by-step process of implementing the moderators and users functionality, starting from the mapping of roles to the actual function calls for granting and revoking roles, and allowing users to remove themselves.

📌 Step 1: Create Mappings for Moderators and Users

To start, we need to define two mappings: one for moderators and one for users. These mappings will track whether a principal holds the moderator or user role.

;; Implementation of custom roles in Clarity
;; Defines a hierarchical structure with 3 levels:
;; admin (top-level) > moderator > user

(define-map moderators principal bool)

(define-map users principal bool)

📌 Step 2: Implement Check Functions: is-moderator and is-user

We need two helper functions that will allow us to check whether the current caller is a moderator or a user. These functions will be essential in restricting access to certain functionalities.

;; read-only functions to check contract-caller role
(define-read-only (is-moderator)
  (default-to false (map-get? moderators contract-caller))
)

(define-read-only (is-user)
  (default-to false (map-get? users contract-caller))
)

📌 Step 3: Granting and Revoking the Moderator Role

Now, we will implement the functionality for admins to grant and revoke the moderator role. Moderators can be assigned or removed only by admins.

;; public function to grant the moderator role

(define-public (add-moderator (address principal))
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (map-set moderators address true)
    (print {
      topic: "Add Moderator",
      calledBy: contract-caller,
      newModerator: address,
    })
    (ok true)
  )
)

;; public function to revoke the moderator role

(define-public (remove-moderator (address principal))
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (map-set moderators address false)
    (print {
      topic: "Remove Moderator",
      calledBy: contract-caller,
      removedModerator: address,
    })
    (ok true)
  )
)

📌 Step 4: Granting and Revoking the User Role

Both admins and moderators can grant or revoke the user role, though moderators can only manage users, not other moderators or admins. Here’s the implementation:

;; public function to grant the user role

(define-public (add-user (address principal))
  (begin 
    (asserts! (or (is-owner) (is-moderator)) NOT_ALLOWED)
    (map-set users address true)
    (print {
      topic: "Add User",
      calledBy: contract-caller,
      newUser: address,
    })
    (ok true)
  )
)

;; public function to revoke the user role

(define-public (remove-user (address principal))
  (begin 
    (asserts! (or (is-owner) (is-moderator)) NOT_ALLOWED)
    (map-set users address false)
    (print {
      topic: "Remove User",
      calledBy: contract-caller,
      removedUser: address,
    })
    (ok true)
  )
)

📌 Step 5: Allow Users to Remove Themselves

One final feature we implement is the ability for users to remove themselves from the system. Users can revoke their own user role, but they must first verify that they actually hold the user role. Here’s how we implement this:

;; public function to remove your user only

(define-public (self-remove)
  (begin 
    (asserts! (is-user) NOT_ALLOWED)
    (map-set users contract-caller false)
    (print {
      topic: "Remove My User",
      calledBy: contract-caller,
      removedUser: contract-caller,
    })
    (ok true)
  )
)

With these steps, we’ve added a flexible and secure role-based system where admins manage moderators, moderators manage users, and users can remove themselves. This hierarchical structure adds granularity to the access control, ensuring a more decentralized and user-friendly contract.

Note that each contract call includes a print statement with a topic to improve transparency and support better auditability.


🧩 Now that we’ve explored ownership and role-based access control in depth, here’s the complete Clarity contract combining all the concepts and best practices discussed above — ready to be reused, extended, or deployed in your own Stacks project.

;; contract errors
(define-constant NOT_THE_OWNER (err u400))
(define-constant NOT_ALLOWED (err u401))

;; tx-sender will be set as first owner of the contract
(define-data-var owner principal tx-sender)

;; variable to track if ownership has been renounced
(define-data-var is-renounced bool false)

;; read-only function to check if the contract caller is the owner
;; the use of contract-caller is safer than tx-sender because allows 
;; only functions calls directly from the owner wallet.
;; if the contract has been renounced it returns false
(define-read-only (is-owner)
    (if (var-get is-renounced) 
      false
      (is-eq contract-caller (var-get owner))
    )
)

;; public function to transfer the ownership,
;; only current owner is allowed to transfer

(define-public (transfer-ownership (address principal))
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (var-set owner address)
    (print {
        topic: "Transfer Ownership",
        calledBy: contract-caller,
        newOwner: address,
    })
    (ok true)
  )
)

;; public function to renounce the ownership
;; only current owner is allowed to renounce
(define-public (renounce-ownership)
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (var-set is-renounced true)
    (print {
        topic: "Renounce Ownership",
        calledBy: contract-caller,
    })
    (ok true)
  )
)

;; Implementation of custom roles in Clarity
;; Defines a hierarchical structure with 3 levels:
;; admin (top-level) > moderator > user

(define-map moderators principal bool)

(define-map users principal bool)

;; read-only functions to check contract-caller role

(define-read-only (is-moderator)
  (default-to false (map-get? moderators contract-caller))
)

(define-read-only (is-user)
  (default-to false (map-get? users contract-caller))
)

;; public function to grant the moderator role

(define-public (add-moderator (address principal))
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (map-set moderators address true)
    (print {
      topic: "Add Moderator",
      calledBy: contract-caller,
      newModerator: address,
    })
    (ok true)
  )
)

;; public function to revoke the moderator role

(define-public (remove-moderator (address principal))
  (begin 
    (asserts! (is-owner) NOT_THE_OWNER)
    (map-set moderators address false)
    (print {
      topic: "Remove Moderator",
      calledBy: contract-caller,
      removedModerator: address,
    })
    (ok true)
  )
)

;; public function to grant the user role

(define-public (add-user (address principal))
  (begin 
    (asserts! (or (is-owner) (is-moderator)) NOT_ALLOWED)
    (map-set users address true)
    (print {
      topic: "Add User",
      calledBy: contract-caller,
      newUser: address,
    })
    (ok true)
  )
)

;; public function to revoke the user role

(define-public (remove-user (address principal))
  (begin 
    (asserts! (or (is-owner) (is-moderator)) NOT_ALLOWED)
    (map-set users address false)
    (print {
      topic: "Remove User",
      calledBy: contract-caller,
      removedUser: address,
    })
    (ok true)
  )
)

;; public function to remove your user only

(define-public (self-remove)
  (begin 
    (asserts! (is-user) NOT_ALLOWED)
    (map-set users contract-caller false)
    (print {
      topic: "Remove My User",
      calledBy: contract-caller,
      removedUser: contract-caller,
    })
    (ok true)
  )
)

🔐 7. Final Thoughts & Best Practices for Secure Smart Contracts

Building a permission system with ownership and custom roles in Clarity not only improves the security of your smart contracts, but also allows for better governance, scalability, and decentralization over time.

Here are some key takeaways and best practices to keep in mind when designing your access control logic:

  • Minimize privileged actions: Limit the number of functions that require admin or owner permissions. The fewer the entry points, the lower the risk.

  • Use contract-caller over tx-sender: Always prefer contract-caller for permission checks. It guarantees that only direct calls from a principal (not inner calls or sub-contracts) are allowed.

  • Emit clear print logs: Including metadata like topic, caller, and affected roles helps auditors and tools trace the full history of sensitive actions.

  • Make renouncement irreversible: Once the owner renounces control, no further privileged changes should be allowed — this is essential for trustless and decentralized deployments.

  • Document role hierarchies clearly: Admins, moderators, users — define their scope explicitly and enforce checks consistently in every function.

  • Test every permission path: From initial ownership transfer, to role assignment, to edge cases like double renounce or unauthorized grants — write thorough tests before deploying.


🙏 Thanks for Reading

I hope this guide helped you understand how to implement secure and modular permission systems in Clarity, inspired by best practices from OpenZeppelin’s Solidity contracts.

If you found it useful, feel free to share it or fork the contract to build your own version!

You can reach out, share feedback, or follow my work on smart contracts, Stacks, and blockchain development here:

🔗 X account
💻 GitHub profile

Stay safe, build trustless. 🚀

0
Subscribe to my newsletter

Read articles from eriq ferrari directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

eriq ferrari
eriq ferrari