Building Permission Systems in Clarity: From Owner to Custom Roles


🧩 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 singleowner
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 variableA modifier like
onlyOwner
to restrict access to certain functionsFunctions to
transferOwnership
orrenounceOwnership
🟫 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:
An
owner
variable to store the admin principalA helper function
is-owner
to check permissionsA
transfer-ownership
function, callable only by the current ownerA
renounce-ownership
function, which sets ais-renounced
flag totrue
, 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
orRenounce 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:
Principal => Bool Mappings: We will create mappings for each role, where a
principal
(an account) is mapped to abool
value indicating whether they hold that role.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
andusers
.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
overtx-sender
: Always prefercontract-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 liketopic
,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:
Stay safe, build trustless. 🚀
Subscribe to my newsletter
Read articles from eriq ferrari directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
