Introduction
Some applications maintain the same access control for years. You use a Login and password to sign in, and then you are permitted to perform operations based on Access Control Lists (ACLs) and flat Roles. The modern era of cloud applications, and the fact that the internet is a space where everything is public, makes this model obsolete. In a world where everyone can access everything, and passwords can be cracked within minutes, we must find new ways to redefine access control.
In this article, we’ll build a full-stack application that demonstrates two new trends in access control: Passkeys and Fine-Grained Authorization.
Let's start with the details of what each of these terms mean.
Authentication - Who Are You?
The first phase in access control is authentication - verifying the identity of the user.
Previously, this verification could be completed by providing an answer to a rather basic question - What do you know?
. This question could have been answered by providing a secret only the user should know - like a password.
These days, data can be easily stolen and exposed to the whole internet - thus, assuming only the user knows the password is not enough.
To strengthen the question of Who are you?
, another question has popped up as a trend in recent years - What do you have?
This question is an example of two-factor authentication. It requires the user to provide a second secret they possess, like a mobile phone or a security key. While this method might be sufficient in some cases, vulnerabilities such as MFA fatigue and others in recent cyber attacks have proven the second authentication factor could be exploited as well.
To solve that, we try today to answer those two questions, but also the overall question of Who are you?
with a third factor - something that proves that the user is indeed who they claim to be. This can be achieved by using biometric data in the form of Passkeys.
Authorization - What You Can Do?
After we manage to identify the user and confirm that they are indeed who they claim to be, the next step is to decide what can they do within our application.
Previously, this question was answered with a simple list of roles and a list of permissions for each role. Today, in a world of distributed applications, we are just using multiple sources for gathering the data we need to streamline our permissions model into a simple list of roles. Not only that, in the world of SaaS, applications depend on 3rd party products (such as Salesforce, for example), creating more factors for policy decisions.
To address this need, it is crucial to apply a more fine-grained approach. Instead of using roles, we define a more complex policy based on attributes (ABAC) and relationships between entities (ReBAC). This way, we can declare policy rules that calculate multiple vectors of data from many factors and decide whether a user can perform an operation or not.
While many developers find themselves mixing this permissions model with their application code, the best practice is to separate the authorization logic from the application code. Two methods we can implement to achieve this are Policy as Code and Policy as Graph , where the policy is defined in a separate file and the data saved in a dedicated Graph DB. This gives developers the ability to define granular and generic permission models that are easily enforced by the application.
The Demo Application
To better demonstrate these methods, we will build a simple note-taking application using two tools - Hanko and Permit.io.
As you can see, the functionality of the application is very simple. An authenticated user can create notes, and each note has a title and a body. We also want to configure the notes in a way that only the user who created a note (or an admin) could delete it.
Instead of building these passkeys and fine-grained authorization from scratch, we will use the following tools:
Hanko - a cloud service and open-source tool that allows us to implement passkey authentication.
Permit.io - a cloud service and open-source tool that allows us to implement fine-grained authorization without having to build it from scratch ourselves.
The application code is built with Next.js, and the code is available on GitHub.
Setup the Application
In the following sections, we will go step-by-step through the implementation of passkeys and fine-grained authorization in our application. We highly recommend cloning the sample application so you can follow the steps more easily.
If you prefer to read the article without cloning the application, you can skip this section and continue to the next one.
Clone the application:
git clone git@github.com:permitio/permit-hanko.git
Install the dependencies:
npm install
Run the application:
npm run dev
At this point, the application will fail to run as we need to set up the Hanko and Permit.io services.
Let's set up the Hanko account so we can log in to the application.
Use Hanko for Passkey Authentication
To use Hanko for passkey authentication, you can either run it locally in your environment or use the cloud service. In this article, we will use the cloud service as it is easier to set up and use.
- Visit the Hanko webapp, create a new organization, and give it the name you want.
- In the main dashboard, create a new project - assign
http://localhost:3000
as the App URL. - From the
Settings
>General
section of the project, copy the API URL. - Paste it to a new file called
.env.local
, in the root directory of the application.
NEXT_PUBLIC_HANKO_API_URL=https://a0ae8d5d-9505-415f-ad70-51839c285726.hanko.io
Now, when we run the application again, we can see that the error is gone, and we can see the login page.
To add this authentication window, we just use the Hanko SDK for JavaScript. You can see the element that implemented the login flow in the app/auth/login/page.tsx file.
<Paper sx={{ p: 2 }}>
<HankoAuth />
</Paper>
We also added a middleware logic in the middleware.ts file that will redirect the user to the login page if they are not authenticated.
const authenticateUser = async (req: NextRequest): Promise<string> => {
if (!hankoApiUrl) {
return "";
}
// Get Hanko token from cookie
const hanko = req.cookies.get("hanko")?.value;
...
// Authenticate user using Hanko
const user = await authenticateUser(req);
// Redirect to login page if user is not authenticated
if (!user) {
urlToRedirect.pathname = LOGIN_URL;
return NextResponse.rewrite(urlToRedirect);
}
With this authentication configuration and flow configured, we are ready to continue with the implementation of Permit.io for authorization in our application.
Use Permit.io for Basic RBAC Authorization
Now, that we are done with the authentication, it is time to set up our permission layer. For the first phase, we will use simple roles to determine the actions that the user can do.
In the /app/api/notes/route.ts, you'll find four functions, GET
, POST
, PUT
, and DELETE
- responsible for the logic of getting, creating, updating, and deleting notes, respectively.
Traditionally, developers would check the user's role in the application code, and decide if the user can perform the operation or not. For example, in our GET
function, they used code that looks like this:
if (user.admin !== true) {
return;
}
This code might be simple, but it is not scalable. If we want to change the role permissions, we need to change the code and redeploy the application. Also, if we would like to make this "flat" permission more fine-grained, we need to add more code and make it more complex.
If you look at the route.ts
file, you'll not find any of those checks. Instead, we are using the generic permit.check middleware.ts file.
const response = await fetch(`${pdpUrl}/allowed`, {
method: "POST",
headers: {
Authorization: `Bearer ${permitApiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
user: user,
action,
resource: resource,
context: {},
}),
});
This code is a generic permit.check function that checks the permissions configured for the application using three factors:
User - the entity that attempts to perform the operation (in our case, a user authenticated with Hanko).
Action - the operation that the user will attempt to perform (in our case, GET
).
Resource - the entity that the user will attempt to perform the operation on (in our case, the note).
At this point, since we haven't configured the Permit SDK key in the app, any user can perform any operation. Setting up Permit.io will fix this.
Now, we can simply configure these permissions in Permit.io, and change them for our needs without changing the application code.
Create a free account on Permit.io website, then create a new organization, and give it the name you want. Skip the rest of the Onboarding wizard
In the left sidebar, click on Policy and then go to the Roles tab, create the following roles:
In the Resources tab, create a new resource called notes, with the following four actions and an owner attribute:
Back in the Policy tab, let's allow all users to
GET
andPOST
notes, and only users with the admin role toPUT
, andDELETE
notes.
Next, we need to place the Permit.io API credentials in our application config.
To use Permit.io in our application, we need first to get the API key from Permit.io dashboard.
-
In the
env.local
file, add the following key
PERMIT_API_KEY=<YOUR_COPIED_API_KEY>
-
We will also want to configure the API endpoint of Permit.io to check the permissions, we will use now in the cloud service.
PERMIT_PDP_URL=https://cloudpdp.api.permit.io
To make sure all environment variables are in place, restart the application.
Now, as we configured the permissions in Permit.io and the app, it is time to sign up with a user and try it out.
Check the Permissions
In localhost:3000
page, login with a user of your choice, and make sure you have the right passkey configured.
In the first screen, let's try to create a note - we can see this note is now on the list.
In the Permit.io dashboard, let's go to the Audit tab in the left sidebar, and we can see that the POST
action is logged. Opening the audit log row, we can see detailed information about this decision, and that the decision happened because this user has the User role.
The way this user got the user role is a sync function we created for every user in our system as the least privileged role.
const response = await permit.api.syncUser({
key,
email,
attributes: {
roles: ["user"],
},
});
await permit.api.roleAssignments.assign({
role: "user",
tenant: "default",
user: key,
});
Now, let's try to delete the note - and we can see that the delete button is returning an error
To change this situation, let's go to the Permit.io dashboard, in the Users
screen edit our user and add an admin role to it.
Returning to the application, we can see that the delete action is now working.
By this simple configuration change, we can see how we can change the permissions of our application without changing the code.
Now that we are done with the RBAC model in our application, let's go to the next phase and make our permissions more fine-grained with no need to change the application code.
Use Permit.io for Fine-Grained ABAC Authorization
Note: For this section to run properly, you need to run the Permit.io PDP as a sidecar, You can read how to do that here.
As stated before, the delete action in our app is supposed to be limited to the same user who created the role.
To achieve that, we can easily configure this policy in Permit.io and see its immediate effect in the application.
- In the Permit.io dashboard, go to ABAC Rules on the Policy page, and enable the ABAC option.
- Create a new Resource Set rule - this set will create a condition that will be true only if the user is the owner of the note.
- Back in the Policy tab, create a new policy that will allow
PUT
, andDELETE
only for the owner of the note. Mind that we leave the privileged admin role to do all actions.
At this point, let's add another user to the application. Let's log out with the current user and sign up with a new user. Let's now try to remove the note that we created with the first user - and we can see that the delete button is returning an error.
Let's now create a new note from this user, to see how we combine the RBAC and ABAC permissions together in our application.
Create a new note with the second user.
Log out from the second user and log in with the first user.
Try to delete the note that we created with the second user - and we can see that delete is successful as the first user is an admin.
If we compare this simple configuration change to the traditional way of implementing permissions, we can see how much time and effort we saved. We also created a more generic and fine-grained permissions model that can be easily changed and configured without changing the application code.
Use Permit.io for Fine-Grained ReBAC Authorization
Another approach for fine-grained authorization that we can use in our application is ReBAC - Relationship-Based Access Control. Assuming a note app, we might want to create workspaces, organizations, and folders for our notes. In this case, we might do not have a dedicated owner field in the note entity, but we have a relationship between the note and the workspace.
As Permit.io also supports the configuration of ReBAC policies, we can easily implement it in our application without changing the permit.check or any enforcement code in the app. To read more about ReBAC and modeling such fine-grained permissions, read here.
Next Steps
In this article, we demonstrated how to implement passkey authentication and fine-grained authorization in a simple note-taking application. We used https://hanko.io and https://www.permit.io/rebac to implement those trends in our application, and we can see how easy it is to implement them with no need to change the application code.
Access control is a very important part of our application, and we must make sure that we implement it in the right way. As next steps, we recommend you to read more on the following topics:
We would also be happy to see you in our Slack community, where you can ask questions and get help from our team and other developers. Join us here.
Top comments (1)
thanks for an amazing explanation!
Hanko >>>