From RBAC to ReBAC and ABAC with Next.js and Permit.io
- Share:
When developing an application, the security of user information is always a top priority. Once a user is authenticated, we also need to decide what they can do within our app. That’s where authorization comes in.
Implementing an authorization model in your app is a complex task. Sure, a very simple RBAC library can suit the simplest of implementations, but as your app gets more complex and requires more nuanced and fine-grained features, authorization gets really complicated really quickly.
In this blog, we’ll talk about how you can implement a strong authorization layer that fits the needs of a more complex application, employing some of the most common authorization models out there - RBAC, ABAC, and ReBAC.
By the end of this, you should have a pretty solid idea of how to model and implement these three models in a Next.js application using the Pemit.io Next.JS SDK. Let’s dive in -
Pink Mobile
So, what are we building?
The Pink mobile app is a mobile-plan management application that allows a fictional mobile service provider, Pink Mobile, to manage customer cellular plans. The application consists of three main personas - Customers, Representatives, and Managers.
The functions, responsibilities, and access requirements of these three personas might not seem like much, but they require the implementation of a pretty intricate and fine-grained authorization layer to function properly and securely.
Overall, our application looks like this:
Before we get into it, here are some helpful links:
- 📚 If you want to check out the documentation for this demo app, it’s available here.
- đź’» The repo for this app, as well as a detailed explanation of how you can run and test it yourself, can be found here.
Let’s figure out what is it exactly our application needs to do -
Overview and Requirements
As shown in the demo above, here’s what our application consists of:
We have three personas with access to the app - Users, Representatives, and Managers:
- Users are the customers using our app. They should have access to their own accounts, granting them the ability to manage their own cellular plans.
- Representatives work at Pink Mobile and should have access to manage certain customer plans and accounts.
- Managers are in charge of assigning representatives to users and have access to view multiple segments of the app.
Dealing with our top-level persona, Managers, is rather straightforward. They should have access based on their pre-defined role, which can be set up using Role Based Access Control (RBAC).
As we can see, our application also contains both a hierarchy between resources (A customer's plan is part of their account), as well as a hierarchy between roles (Account editors can also manage plans).
The fact that we are dealing with hierarchies already tells us that we would have to utilize a policy model suited to consider relationships between identities and resources - that’s Relationship Based Access Control (ReBAC).
On top of that, we want to include two additional conditions:
- We want to be able to define Blocked Users (Due to lack of payment, for instance) and limit their activity within the app.
- We want to be able to define Ownership - making sure a user’s plan belongs to them before we allow them to perform actions on it.
To handle these requirements, we will define two attributes:
user.blocked = true
plan.owner = user.id
Since we need to consider attributes as part of our authorization policy, we will also need to utilize Attribute Based Access Control (ABAC) as part of our authorization layer.
The functionalities of our application (Which, compared to most apps you’ve probably used, aren’t particularly complex) require the use of all three most common authorization models - RBAC, ABAC, and ReBAC. Having a basic understanding of what we’re looking to build, let’s start mapping our actual implementation.
Mapping the Authorization Layer - The Control Plane
As we saw in the previous section, the authorization layer for this application consists of several parts -
In this section, we configure the control plane of our app’s authorization. After we set that up, we can proceed to configure the actual data our application will work with.
Let’s go over each part of this control plane and figure out exactly how it should work -
Resources
The first step in protecting data is understanding which resources we wish to protect. Our app has four types of resources:
User
: Customers who should have access to their plan and accountRepresentative
: An agent who can manage customersAccount
: A top-level resource for everything user-related (e.g., personal details)Plan
: A user's mobile plan
In an actual mobile plan management application, we would, of course, have many more resources. For the purpose of this tutorial, we’re going to stick to these four.
Here’s how setting up the Representative
resource would look like:
await permit.api.resources.create({
key: "representative",
name: "Representative",
actions: {
list: {
name: "List",
},
assign: {
name: "Assign",
},
},
});
We can see here that it would be possible to perform two actions on a Representative
resource - List
and Assign
. As we previously discussed, these actions will be reserved for those with the Manager role.
The Account
resource acts as a top-level resource for the user. This means the account permissions need to be propagated to all resources related to this account. A user's plan is considered part of their account, so every action a user can perform on their account should be available on their plan as well.
To create this kind of hierarchal relationship, we need to declare the relations between resources in our system. Here's an example of how we can declare the relationship between the Account
 and Plan
 resources using the Permit Node.js SDK:
await permit.api.resources.create({
key: "plan",
name: "Plan",
...
relations: {
parent: "account",
},
});
Roles
Now that we've modeled and configured the resources, we can define the different roles that are allowed to perform operations on these resources. The Pink mobile app has two types of roles:
Environment-level Roles
These roles allow users to perform operations on all the resources of a particular type in an environment.
Manager
- An administrator who managesRepresentatives
. Is able toList
andAssign
Representatives
, as well as viewUser
accounts, view Plans, and create newUsers
.
This policy is based on rather simple Role Based Access Control (RBAC) principles.Representative
- An agent who can manageUsers
and theirPlans
, as well asList
Users
. Here, the permissions are based on both RBAC and the relationships between agents and their customers.
Here's a code example of adding the RBAC-based Manager role to the environment using the Permit Node.js SDK:
await permit.api.roles.create({
key: "manager",
name: "Manager",
permissions: [
"representatives:assign",
"account:view",
"users:create",
"representatives:list",
"plan:view",
"users:list",
],
});
Resource-level Roles
These roles are more fine-grained, and allow to perform operations on a specific resource a user is assigned to. The format for these roles will look like this: Resource#Role
Account#Owner
: Can perform any operation aUser
Account
Account#Member
: Can view aUser
Account
Account#Editor
: Can edit aUser
Account
Plan#Editor
: Can edit aUser
Plan
Here's how we can add the Account#Owner
 role as part of the resource configuration using the Permit Node.js SDK:
await permit.api.resources.create({
key: "plan",
name: "Plan",
...
roles: {
editor: {
name: "Editor",
permissions: ["view", "change"],
...
},
},
...
});
Role Derivations
Just like hierarchy within our resources, we also need to create a hierarchy within our roles.
A User
with the Account#Owner
role, for example, should receive additional permissions to related resources, such as their Plan
.
This permission hierarchy can be created by utilizing Role Derivation, deriving permissions from the Account
to the Plan
based on the relationship between them. The role derivation will be configured as such:
Account#Editor
-> Plan#Editor
Every user with the Account#Editor
role, receives all relevant permissions in the Plan#Editor
role.
Here's a code example of adding role derivation to the environment using the Permit Node.js SDK:
await permit.api.resources.create({
key: "plan",
name: "Plan",
...
roles: {
editor: {
name: "Editor",
permissions: ["view", "change"],
granted_to: {
users_with_role: [
{
role: "editor",
on_resource: "account",
linked_by_relation: "parent",
},
],
},
},
},
...
});
Representatives are assigned the Account#Editor
for the Users
they manage. This also grants them the Plan#Editor
role on those accounts, allowing them to edit User
plans. Nice.
At this point, we have the following functionality set up in our authorization system:
Managers
can view Accounts and Plans, as well as list representatives and users, assign representatives to users, and create new ones. These policies are role-based.Representatives
, based on their role, can list users. On top of that, after being assigned theAccount#Editor
role to aUser
by aManager
, they also receive thePlan#Editor
through role derivation, allowing them to editUser
plans.
Going Deeper - ABAC and Condition Sets
To create even fine-grained authorization, we can use Attribute Based Access Control (ABAC), leveraging resource and user attributes to create condition sets based on our resources. This will allow us to build policies based on these conditions.
User Sets
The first type of condition we can create is a user set - a condition (or set of conditions) based on user attributes. Let’s set up the following conditions:
- Blocked Users: A condition that checks whether a user is blocked -
user.blocked == true
- Active Users: A condition that checks if the user is active -
user.blocked == false
Here's an example of how to create a Blocked Users
condition using the Permit Node.js SDK:
await permit.api.conditionSets.create({
key: "blocked_users",
name: "Blocked Users",
type: "userset",
conditions: {
allOf: [{ allOf: [{ "user.blocked": { equals: true } }] }],
},
});
Resource Sets
The second type of condition we can create is a resource set. This time, the condition will be based on resource attributes. In a resource set, we can utilize user attributes together with resource attributes to test for things like ownership. Let’s set up the following condition:
- Owned Resources: A condition that checks if a User is the owner of a resource -
resource.owner == [user.id]
Here's how to create an Owned Resource
condition using the Permit Node.js SDK:
await permit.api.conditionSets.create({
key: "owned_plans",
name: "Owned Plans",
type: "resourceset",
resource_id: "plan",
conditions: {
allOf: [
{ allOf: [{ "resource.owner": { equals: { ref: "user.key" } } }] },
],
},
});
With these conditions set up, we can define some additional policies and add them to the mix:
- Active users can view and change plans owned by them
- Blocked users can only view plans owned by them
To set this policy up, we used the Permit API in the setup script. Here's where we assigned active users permissions to perform operations on their owned resources:
await permit.api.conditionSetRules.create({
user_set: "active_users",
resource_set: "owned_plans",
permission: "plan:view",
});
await permit.api.conditionSetRules.create({
user_set: "active_users",
resource_set: "owned_plans",
permission: "plan:change",
});
With all of our policies setup, this is what they should look like in the Permit UI -
Note that the UI contains both the RBAC, ABAC, and ReBAC policies, all together in one interface.
The Data Plane
With our authorization's control plane set up, it’s time to test it out with actual data (well, demo data, but still). The most basic entity in every authorization data plane is the user. A user’s basic information and unique ID will help us make the decision if they are allowed or denied from performing operations.
Users
In a standard application, Permit's sync.user
function would be connected to your authentication provider, importing user IDs from there. For the sake of simplicity in this app, we just used the Setup.js
script to configure 6 users and assign each of them different roles and attributes.
Here's an example of how we configured a user called Sirius Black:
permit.api.users.sync({
email: "sirius@pink.mobile",
key: "sirius@pink.mobile",
first_name: "Sirius",
last_name: "Black",
attributes: {},
});
In a real-life scenario, you wouldn’t want to pass all of this information through the authorization layer. When using Permit in production, it’s enough to pass a unique user key.
Roles
For each user in our application, we would like to assign one or more resource or environment roles. This will help us match the user with their proper permissions.
Here's how we assigned the Representative
role to Sirius Black:
await permit.api.roleAssignments.assign({
role: "representative",
user: "sirius@pink.mobile",
tenant: "default",
});
Here's an example of a resource-role assignment, where we assign harry@potter.io the Account#Owner
role for his account instance:
await permit.api.roleAssignments.assign({
role: "owner",
resource_instance: `account:harry`,
user: "harry@potter.io",
tenant: "default",
});
Relationship Tupples
To ensure roles are derived correctly from one resource to another, we need to create relationship tuples between the resources.
In this demo app, we used the Setup.js
script to configure the relationship tuples between the resources. Here's how we configured the relationship between Harry's account and Harry's plan:
await permit.api.relationshipTuples.create({
subject: `account:harry`,
object: `plan:harry`,
relation: "parent",
tenant: "default",
})
The Enforcement Plane
With our authorization layer and all relevant data set up, it’s time to enforce our permissions!
In the authorizer.js
file, you'll find the function responsible for enforcing the policy decisions. Here's an example of how we enforce the policy decisions for the Change Plan
operation:
export const authorize = async (user, action, resource) => {
return permit.check(user, action, resource);
};
The permit.check
function takes the current user, operation, and resource and returns a decision based on the policy we had previously configured, along with the data configured in the data plane.
With our control and data planes set up, we now have a fully functional authorization layer integrated into our demo application! You can easily see, of course, how this could be expanded further to accommodate the needs of a real app in prod, but the basics are all here - RBAC, ABAC, ReBAC, and sample data that ties it all together.
What’s Next?
The Pink Mobile demo app explored here should serve as a valuable blueprint for any app developer looking to better understand authorization for complex applications and the benefits of creating it with the help of Permit. We learned how even an application that might look quite simple at first might require implementing RBAC, ABAC, and ReBAC, and how you can create and implement all three policy models by using Permit.
Want to learn more about Authorization? Join our Slack community, where there are hundreds of devs building and implementing authorization.
Written by
Daniel Bass
Application authorization enthusiast with years of experience as a customer engineer, technical writing, and open-source community advocacy. Comunity Manager, Dev. Convention Extrovert and Meme Enthusiast.