Authorization is critical to web applications. It grants the correct users access to sections of your web application on the basis of their roles and permissions. In a simple application, adding in-app authorization to your application is relatively straightforward. But with complex applications comes a need to create different roles and permissions, which can become difficult to manage.
In this tutorial, you’ll learn how to use Cerbos to add authorization to a Node.js web application, simplifying the authorization process as a result.
Before we get started with Cerbos, you’ll need to create a new Node.js application (or use an existing one). Let’s set up a blog post Node.js application as our example.
The blog post application will contain two roles: member and moderator.
The member role will have the following permissions:
The moderator role will have the following permissions:
Members and moderators cannot perform any action if they are disabled.
Launch your terminal or command-line tool and create a directory for the new application:
mkdir blogpost
Move into the blog post directory and run the command below—a
package.json
file will be created:
npm init -y
Open the package.json
file and paste the following:
{ "name": "blogpost", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "nodemon index.js", "test": "mocha --exit --recursive test/**/*.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@cerbos/grpc": "^0.6.0", "express": "^4.17.1" }, "devDependencies": { "chai": "^4.3.4", "chai-http": "^4.3.0", "mocha": "^9.0.3", "nodemon": "^2.0.12" } }
Two main packages are in the dependencies section of the
package.json
—Cerbos and Express:
In the devDependencies
, there are four packages: Chai, Chai HTTP,
Mocha, and Nodemon. Chai, Chai HTTP, and Mocha are used to run automated test
scripts during and after development. Nodemon is used to ensure the
application server is restarted whenever a change is made to any file during
development.
Run npm install
to install the packages in the package.json.
Create the following files:
index.js
, which contains the base configuration of the demo
application.
routes.js
, which contains all the routes needed in the demo
application.
db.js
, which exports the demo database. For the sake of this
demo, you will be using an array to store the data—you can use any database
system you desire.
authorization.js
, which contains the Cerbos authorization
logic.
touch index.js routes.js db.js authorization.js
Then, paste the following codes in the respective files:
//index.js const express = require("express"); const router = require("./routes"); const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use("/posts", router); app.use((error, req, res, next) => { res.status(400).json({ code: 400, message: error.stack, }); }); app.listen(8000, () => { console.log("App listening on port 8000!"); }); module.exports = app; //routes.js const express = require("express"); const router = express.Router(); const db = require("./db"); const authorization = require("./authorization"); const checkPostExistAndGet = (id) => { const getPost = db.posts.find((item) => item.id === Number(id)); if (!getPost) throw new Error("Post doesn't exist"); return getPost; }; router.post("/", async (req, res, next) => { try { const { title, content } = req.body; const { user_id: userId } = req.headers; await authorization(userId, "create", req.body); const newData = { id: Math.floor(Math.random() * 999999 + 1), title, content, userId: Number(userId), flagged: false, }; db.posts.push(newData); res.status(201).json({ code: 201, data: newData, message: "Post created successfully", }); } catch (error) { next(error); } }); router.get("/", async (req, res, next) => { try { const getPosts = db.posts.filter((item) => item.flagged === false); const { user_id: userId } = req.headers; await authorization(userId, "view:all"); res.json({ code: 200, data: getPosts, message: "All posts fetched successfully", }); } catch (error) { next(error); } }); router.get("/:id", async (req, res, next) => { try { const getPost = db.posts.find( (item) => item.flagged === false && item.id === Number(req.params.id) ); const { user_id: userId } = req.headers; await authorization(userId, "view:single"); res.json({ code: 200, data: getPost, message: "Post fetched successfully", }); } catch (error) { next(error); } }); router.patch("/:id", async (req, res, next) => { try { const { title, content } = req.body; let updatedContent = { title, content }; const { user_id: userId } = req.headers; const postId = req.params.id; checkPostExistAndGet(postId); const tempUpdatedPosts = db.posts.map((item) => { if (item.id === Number(postId)) { updatedContent = { ...item, ...updatedContent, }; return updatedContent; } return item; }); await authorization(userId, "update", updatedContent); db.posts = tempUpdatedPosts; res.json({ code: 200, data: updatedContent, message: "Post updated successfully", }); } catch (error) { next(error); } }); router.delete("/:id", async (req, res, next) => { try { const { user_id: userId } = req.headers; const postId = req.params.id; const post = checkPostExistAndGet(postId); const allPosts = db.posts.filter( (item) => item.flagged === false && item.id !== Number(postId) ); await authorization(userId, "delete", post); db.posts = allPosts; res.json({ code: 200, message: "Post deleted successfully", }); } catch (error) { next(error); } }); router.post("/flag/:id", async (req, res, next) => { try { let flaggedContent = { flagged: req.body.flag, }; const { user_id: userId } = req.headers; const postId = req.params.id; checkPostExistAndGet(postId); const tempUpdatedPosts = db.posts.map((item) => { if (item.id === Number(postId)) { flaggedContent = { ...item, ...flaggedContent, }; return flaggedContent; } return item; }); await authorization(userId, "flag", flaggedContent); db.posts = tempUpdatedPosts; res.json({ code: 200, data: flaggedContent, message: `Post ${req.body.flag ? "flagged" : "unflagged"} successfully`, }); } catch (error) { next(error); } }); module.exports = router; //db.js const db = { users: [ { id: 1, name: "John Doe", role: "member", blocked: false, }, { id: 2, name: "Snow Mountain", role: "member", blocked: false, }, { id: 3, name: "David Woods", role: "member", blocked: true, }, { id: 4, name: "Maria Waters", role: "moderator", blocked: false, }, { id: 5, name: "Grace Stones", role: "moderator", blocked: true, }, ], posts: [ { id: 366283, title: "Introduction to Cerbos", content: "In this article, you will learn how to integrate Cerbos authorization into an existing application", userId: 1, flagged: false, }, ], }; module.exports = db;
The demo database includes five users, consisting of three members and two moderators. Among the three members, there are two active members and one blocked member. Among the two moderators, one is an active moderator and the other is a blocked moderator.
In the meantime, the authorization.js
will contain an empty
scaffolding to see how the application works, before integrating the Cerbos
authorization package:
module.exports = async (principalId, action, resourceAtrr = {}) => { };
The demo application has been successfully set up. It’s now time to see how the application looks before integrating the Cerbos authorization package.
Start the server with the command below:
npm run start
You should see the following in your terminal to indicate your application is running on port 8000:
[nodemon] 2.0.12 [nodemon] to restart at any time, enter `rs` [nodemon] watching path(s): *.* [nodemon] watching extensions: js,mjs,json [nodemon] starting `node index.js` App listening on port 8000!
Now it’s time to test the application. You can use any HTTP client of your choice, such as Postman, Insomnia, or cURL. For this example, we’ll use cURL.
Make the following requests—you should find no restrictions. Change the user_ID from 1 through 5, and you should receive a valid response.
curl --location --request POST 'http://localhost:8000/posts/' --header 'user_id: 1' --header 'Content-Type: application/json' --data-raw '{ "title": "Introduction to Cerbos", "content": "Welcome to Cerbos authorization package" }'
curl --request PATCH 'http://localhost:8000/posts/351886' --header 'user_id: 1' --header 'Content-Type: application/json' --data-raw '{ "title": "Welcome to Cerbos", "content": "10 things you need to know about Cerbos" }'
curl --request GET 'http://localhost:8000/posts/' --header 'user_id: 1'
curl --request GET 'http://localhost:8000/posts/366283' --header 'user_id: 1'
curl --request POST 'http://localhost:8000/posts/flag/366283' --header 'user_id: 5' --header 'Content-Type: application/json' --data-raw '{ "flag": true }'
curl --request DELETE 'http://localhost:8000/posts/366283' --header 'user_id: 1'
As things stand, the application is open to authorized and unauthorized actions. Now, it’s time to implement Cerbos to ensure users perform only authorized operations.
To get started, a policy folder needs to be created to store Cerbos policies. Cerbos uses these policies to determine which users have access to what resources. In the blog post directory, run the command below to create a directory called Cerbos. This will contain the policy directory:
mkdir cerbos && mkdir cerbos/policies
Next, switch to the policies folder and create two policy YAML files:
derived_roles.yaml
and resource_post.yaml
.
Derived roles allow you to create dynamic roles from one or more parent roles. For example, the role member is permitted to view all blog posts created by other members, but is not allowed to perform any edit operation. To allow owners of a blog post who are also members make edits on their blog post, a derived role called owner is created to grant this permission.
Now paste the code below in your derived_roles.yaml
:
--- # derived_roles.yaml apiVersion: "api.cerbos.dev/v1" derivedRoles: name: common_roles definitions: - name: all_users parentRoles: ["member", "moderator"] condition: match: expr: request.principal.attr.blocked == false - name: owner parentRoles: ["member"] condition: match: all: of: - expr: request.resource.attr.userId == request.principal.attr.id - expr: request.principal.attr.blocked == false - name: member_only parentRoles: ["member"] condition: match: expr: request.principal.attr.blocked == false - name: moderator_only parentRoles: ["moderator"] condition: match: expr: request.principal.attr.blocked == false - name: unknown parentRoles: ["unknown"]
The resource policy file allows you to create rules for parent/derived roles on different actions that can be performed on a resource. These rules inform the roles if they have permission to perform certain actions on a resource.
Paste the following code in your resource_post.yaml
:
--- # resource_post.yaml apiVersion: api.cerbos.dev/v1 resourcePolicy: version: "default" importDerivedRoles: - common_roles resource: "blogpost" rules: - actions: ['view:all'] effect: EFFECT_ALLOW derivedRoles: - all_users - actions: ['view:single'] effect: EFFECT_ALLOW roles: - moderator - member - actions: ['create'] effect: EFFECT_ALLOW derivedRoles: - member_only - actions: ['update'] effect: EFFECT_ALLOW derivedRoles: - owner - moderator_only condition: match: any: of: - expr: request.resource.attr.flagged == false && request.principal.attr.role == "member" - expr: request.resource.attr.flagged == true && request.principal.attr.role == "moderator" - actions: ['delete'] effect: EFFECT_ALLOW derivedRoles: - owner - actions: ['flag'] effect: EFFECT_ALLOW derivedRoles: - moderator_only
The resource policy file contains the permissions each role or derived roles can have access to:
To ensure your policy’s YAML files do not contain errors, run this command in the blog post root directory. If it doesn’t return anything, then it is error-free:
docker run --rm --name cerbos -v $(pwd)/cerbos/policies:/policies ghcr.io/cerbos/cerbos:latest compile /policies
You’ve now successfully created the policy files that Cerbos will be using to authorize users in your application. Next, it’s time to spin the Cerbos server up by running the below command in your terminal:
docker run --rm --name cerbos -v $(pwd)/cerbos/policies:/policies -p 3592:3592 -p 3593:3593 ghcr.io/cerbos/cerbos:latest
Your Cerbos server should be running at http://localhost:3592. Visit the link, and if no error is returned the server is working fine.
Now it’s time to fill the empty scaffolding in the
authorization.js
file:
const { GRPC } = require("@cerbos/grpc"); const { users } = require("./db"); // The Cerbos PDP instance const cerbos = new GRPC("localhost:3593", { tls: false, }); const SHOW_PDP_REQUEST_LOG = false; module.exports = async (principalId, action, resourceAtrr = {}) => { const user = users.find((item) => item.id === Number(principalId)); const cerbosObject = { resource: { kind: "blogpost", policyVersion: "default", id: resourceAtrr.id + "" || "new", attributes: resourceAtrr, }, principal: { id: principalId + "" || "0", policyVersion: "default", roles: [user?.role || "unknown"], attributes: user, }, actions: [action], }; SHOW_PDP_REQUEST_LOG && console.log("cerbosObject \n", JSON.stringify(cerbosObject, null, 4)); const cerbosCheck = await cerbos.checkResource(cerbosObject); const isAuthorized = cerbosCheck.isAllowed(action); if (!isAuthorized) throw new Error("You are not authorized to visit this resource"); return true; };
The cerbosObject
is the controller that checks if a user has
access to certain actions. It contains the following keys:
The cerbosCheck.checkResource()
method is used to check if the
user/principal is authorized to perform the requested action at that instance.
You have successfully set up the required roles and permissions for each operation in the CRUD blog post demo application. It’s now time to test the routes again and observe what happens, using the table below as a guide for testing:
actionuser_iduser_roleuser_statusresponsecreate, view:all, view:single1 and 2memberactiveOKAll Actions3memberblockedNot authorizedAll Actions5moderatorblockedNot authorizedUpdate its own post1memberactiveOKUpdate another user post1memberactiveNot authorized The above table displays a subset of the different permissions for each user implemented in the demo application.
You can clone the demo application repository from GitHub. Once you’ve cloned it, follow the simple instructions in the README file. You can run the automated test script to test for the different user roles and permissions.
Cerbos is a sophisticated authorization engine that offers developers fine-grain access control to enhance the security of their app while saving development time and ensuring future scalability. When Cerbos is used for NodeJS authorization the result is a versatile, secure app with numerous advantages unavailable to those attempting to handle authorization strictly through the NodeJS development process. Here are a few examples of these advantages:
Greater security: As threats to digital infrastructure grow ever more numerous and pressing security becomes an even more compelling concern for app developers. When you integrate Cerbos authorization into your NodeJS application you enable the kind of fine-grain control that helps reduce security threats before they ever materialize. Eliminate unauthorized access and enhance the overall secure profile of your NodeJS app with Cerbos.
Scalability: It’s imperative that your app be able to scale with you as your company grows and your needs change. Developers appreciate the scalability provided by NodeJS and its event-driven architecture. What they may not realize is that Cerbos, with its centralized policy management system, is also easily scalable and will allow your app to remain secure with no disruption to service or performance while accommodating increased amounts of traffic.
Easy Compliance: Cerbos generates highly detailed logs noting precisely who accessed what and when. This degree of insight is an invaluable tool for companies striving to meet their compliance obligations. By opting to have Cerbos handle your NodeJS authorization you automatically create a detailed audit path that ensures activity adheres to your own internal compliance standards, while also facilitating official investigations.
Flexibility: In today's fast-moving technological landscape flexibility is everything. Fortunately, Cerbos is known to align seamlessly with the flexible nature of NodeJS apps. Dynamic access control policies can be developed and managed from a centralized point that enable you to restrict access based on user attributes. Attributes that can be modified quickly and easily on the fly to adapt to changing circumstances. With Cerbos handling NodeJS authorization you're ready for anything.
Integrating Cerbos authorization into your NodeJS application will produce all of the above-listed benefits and more. Combining Cerbos with the inherent flexibility of your NodeJS application will produce an app that’s powerful, versatile, scalable and secure. If you are looking for a way to optimize the potential and performance of your app, it’s in your interest to choose Cerbos as your NodeJS authorization engine.
In this article, you’ve learned the benefits of Cerbos authorization by implementing it in a demo Node.js application. You’ve also learned the different Cerbos policy files and their importance in ensuring authorization works properly.
For more information about Cerbos, you can visit the official documentation here.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.