Building RBAC in Node

Introduction

In this post, we’ll review some of the ways to implement an RBAC pattern in a Node.js application using several open source libraries as well as the Aserto Express.js SDK. This is by no means an exhaustive guide for all the features the libraries provide, but it should give you a good idea of how to use them.

Prerequisites

  • To follow this post, you’ll need a basic understanding of Javascript and Node.js.
  • You’ll need Node.js and Yarn installed on your machine.
  • You should be familiar with Rick and Morty — otherwise, these users are going to make no sense ;-)

Setup

All of the examples we’ll demonstrate in this post have a similar structure:

  • They use Express.js as the web server, and they use a middleware called hasPermission to check if the user has the correct permissions to access the route.
  • They share a users.json file that contains the users and their assigned roles. This file will simulate a database that would be used in a real application to store and retrieve user information.
[
{
"id": "beth@the-smiths.com",
"roles": ["clone"]
},
{
"id": "morty@the-citadel.com",
"roles": ["sidekick"]
},
{
"id": "rick@the-citadel.com",
"roles": ["evilGenius", "squanch"]
}
]
  • The users.json file is going to be accessed by a function called resolveUserRole which, given a user will resolve their role. This function is shared by all of the examples and is found in utils.js.
const users = require("./users");
const resolveUserRole = (user) => {
//Would query DB
const userWithRole = users.find((u) => u.id === user.id);
return userWithRole.role;
};
  • The initial setup for the Express.js app is straightforward:
const express = require("express");
const { resolveUserRoles } = require("../utils");
const app = express();
app.use(express.json());
  • The application will have three routes that will be protected by the hasPermission middleware, which will determine whether the user has the correct permissions to access the route, based on the action associated with that route.
app.get("/api/:asset", hasPermission("gather"), (req, res) => {
res.send("Got Permission");
});
app.put("/api/:asset", hasPermission("consume"), (req, res) => {
res.send("Got Permission");
});
app.delete("/api/:asset", hasPermission("destroy"), (req, res) => {
res.send("Got Permission");
});
  • And finally, the application will listen on port 8080:
app.listen(8080, () => {
console.log("listening on port 8080");
});

Testing

curl -X <HTTP Verb> --location 'http://localhost:8080/api/<asset>' \
--header 'Content-Type: application/json' \
--data-raw '{
"user": {
"id": "krisj@acmecorp.com"
}
}'

Where <HTTP Verb> is either GET, PUT, or DELETE and <asset> is either megaSeeds or timeCrystals.

For each user, we’ll expect the following:

  • Beth (AKA the clone): Should be only able to gather megaSeeds and timeCrystals
  • Morty (AKA the sidekick): Should be only able to gather and consume megaSeeds and timeCrystals
  • Rick (AKA the evilGenius): Should be able to gather, consume and destroy only megaSeeds and timeCrystals.

Let’s go get those mega seeds!

Vanilla Node.js

{
"clone": {
"gather": ["megaSeeds", "timeCrystals"]
},
"sidekick": {
"gather": ["megaSeeds", "timeCrystals"],
"consume": ["megaSeeds", "timeCrystals"]
},
"evilGenius": {
"gather": ["megaSeeds", "timeCrystals"],
"consume": ["megaSeeds", "timeCrystals"],
"destroy": ["megaSeeds", "timeCrystals"]
}
}

In this JSON snippet, the clone role will only be able to gather the megaSeeds and timeCrystals assets. The sidekick role will be able to gather and consume the megaSeeds and timeCrystals assets. The evilGenius role will be able to gather, consume, and destroy megaSeeds and timeCrystals.

The implementation of the hasPermission middleware function is going to be very simple:

const hasPermission = (action) => {
return (req, res, next) => {
const { user } = req.body;
const { asset } = req.params;
const userRoles = resolveUserRoles(user);
const permissions = userRoles.reduce((perms, role) => {
perms =
roles[role] && roles[role][action]
? perms.concat(roles[role][action])
: perms.concat([]);
return perms;
}, []);
const allowed = permissions.includes(asset); allowed ? next() : res.status(403).send("Forbidden").end();
};
};

In this example we:

  1. Iterate over each user role
  2. Check the existence of the user’s given role in the roles object
  3. Check the existence of actions within that given role, and finally, check if the assets array associated with that role and action contains the asset the user is trying to access.
  4. Determine whether the permissions the user has included the asset they are trying to access.

Other than being pretty simplistic, this approach is not going to be very scalable — the “policy” definition is going to become complex, highly repetitive, and thus hard to maintain.

Click here to view the full vanilla Node.js implementation.

Node-Casbin

In Casbin, the access control model is encapsulated in a configuration file (src/rbac_model.conf):

[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[matchers]
m = g(r.sub , p.sub) && r.obj == p.obj && r.act == p.act
[policy_effect]
e = some(where (p.eft == allow))

Along with a policy/roles definition file (src/rbac_policy.conf)

p, clone, megaSeeds, gather
p, clone, timeCrystals, gather
p, sidekick, megaSeeds, consume
p, sidekick, timeCrystals, consume
p, evilGenius, megaSeeds, destroy
p, evilGenius, timeCrystals, destroy
g, sidekick, clone
g, evilGenius, sidekick
  • The request_definition section defines the request parameters. In this case, the request parameters are the minimally required parameters: subject (sub), object (obj) and action (act). It defines the parameters' names and order that the policy matcher will use to match the request.
  • The policy_definitions section dictates the structure of the policy. In our example, the structure matches that of the request, containing the subject, object, and action parameters. In the policy/roles definition file, we can see that there are policies (on lines beginning with p) for each role (clone, sidekick, and evilGenius)
  • The role_definition sections is specific for the RBAC model. In our example, the model indicates that an inheritance group (g) is comprised of two members. In the policy/roles definition file, we can see two role inheritance rules for sidekick and evilGenius, where sidekick inherits from clone and evilGenius inherits from sidekick (which means the evilGenius will also have the clone permissions).
  • The matchers sections defines the matching rules for policy and the request. In our example, the matcher is going to check whether each of the request parameters match the policy parameters, and that the role r.sub is in the policy.

The implementation of the hasPermission middleware function for Node-Casbin is as follows:

const hasPermission = (action) => {
return async (req, res, next) => {
const { user } = req.body;
const { asset } = req.params;
const userRoles = resolveUserRoles(user);
const e = await newEnforcer("./rbac_model.conf", "./rbac_policy.csv"); const allowed = await userRoles.reduce(async (perms, role) => {
const acc = await perms;
if (acc) return true;
const can = await e.enforce(role, asset, action);
if (can) return true;
}, false);
allowed ? next() : res.status(403).send("Forbidden").end();
};
};

In this code snippet, we create a new Casbin enforcer using the newEnforcer function. Then, we call e.enforce(role, asset, action) on each user role, and return true as soon as the result of the e.enforce function is true. We return a 403 Forbidden response if the user is not allowed to perform the action on the asset, otherwise we call the next function to continue the middleware chain.

Click here to view the full Node-Casbin implementation.

CASL

The main concept in CASL is the “Ability”, which determines what a user is able to do in the applications.

It uses a declarative syntax to define abilities, as seen below:

import { AbilityBuilder, Ability } from "@casl/ability";
import { resolveUserRoles } from "../utils.js";
export function defineRulesFor(user) {
const { can, rules } = new AbilityBuilder(Ability);
// If no user, no rules
if (!user) return new Ability(rules);
const roles = resolveUserRoles(user);
roles.forEach((role) => {
switch (role) {
case "clone":
can("gather", "Asset", { id: "megaSeeds" });
can("gather", "Asset", { id: "timeCrystals" });
break;
case "sidekick":
can("gather", "Asset", { id: "megaSeeds" });
can("gather", "Asset", { id: "timeCrystals" });
can("consume", "Asset", { id: "timeCrystals" });
can("consume", "Asset", { id: "megaSeeds" });
break;
case "evilGenius":
can("manage", "all");
break;
default:
// anonymous users can't do anything
can();
break;
}
});
return new Ability(rules);
}

In this code snippet, we resolve the user’s role using the same resolveUserRoles utility function. Since CASL doesn't have the notion of a role, we create a switch statement that handles the assignment of permission for the various roles. For each role, we call the can function which assigns a particular action (gather, consume, or destroy) to a particular resource model (Asset) with specific conditions (id has to equal the asset specified). In the case of the evilGenius role, we use the reserved manage keyword - which means the user can perform all actions, and the reserved all keyword that indicates that this role can do execute actions on all assets.

The hasPermission middleware function for CASL is very similar to the one we used in the previous example:

const hasPermission = (action) => {
return (req, res, next) => {
const { user } = req.body;
const { asset: assetId } = req.params;
const ability = defineRulesFor(user);
const asset = new Resource(assetId);
try {
ForbiddenError.from(ability).throwUnlessCan(action, asset);
next();
} catch (error) {
res.status(403).send("Forbidden").end();
}
};
};

The ability is defined by the rules set by the defineRulesFor function. Then, we wrap the error handler ForbiddenError.from(ability)... that will throw unless that ability allows the user to perform the action on the asset we pass to it. If no error is thrown, we call the next function to continue the middleware chain, otherwise, we return a 403 Forbidden response.

Click here to view the full CASL implementation.

RBAC

The policy definition is a JSON object passed to the RBAC constructor:

const { RBAC } = require("rbac");
const policy = new RBAC({
roles: ["clone", "sidekick", "evilGenius"],
permissions: {
megaSeeds: ["gather", "consume", "destroy"],
timeCrystals: ["gather", "consume", "destroy"],
},
grants: {
clone: ["gather_megaSeeds", "gather_timeCrystals"],
sidekick: ["clone", "consume_megaSeeds", "consume_timeCrystals"],
evilGenius: ["sidekick", "destroy_megaSeeds", "destroy_timeCrystals"],
},
});

This code snippet defines the possible roles used in the policy, the possible actions for each asset and eventually defines the mapping between the possible roles and the combination of actions and assets. The combination of actions and assets is simply the concatenation of the action string, an underscore, and the asset. We can see that sidekick also inherits the clone role, and evilGenius also inherits the sidekick role.

The hasPermission middleware function is again similar to the one we used in the previous examples, where the only difference is the call to the policy object:

const hasPermission = (action) => {
return async (req, res, next) => {
const { user } = req.body;
const { asset } = req.params;
const userRoles = resolveUserRoles(user);
const allowed = await userRoles.reduce(async (perms, role) => {
const acc = await perms;
if (acc) return true;
const can = await policy.can(role, action, asset);
if (can) return true;
}, false);
allowed ? next() : res.status(403).send("Forbidden").end();
};
};

Click here to view the full RBAC implementation.

Access-Control

In this example, we define the roles and permissions in a file called grantlist.js:

const grantList = [
{ role: "evilGenius", asset: "megaSeeds", action: "delete:any" },
{ role: "evilGenius", asset: "timeCrystals", action: "delete:any" },
{
role: "evilGenius",
asset: "megaSeeds",
action: "read:any",
},
{ role: "editor", asset: "megaSeeds", action: "update:any" },
{ role: "editor", asset: "timeCrystals", action: "update:any" },
{
role: "editor",
asset: "megaSeeds",
action: "read:any",
attributes: ["*", "!id"],
},
{ role: "user", asset: "megaSeeds", action: "read:any" },
{ role: "user", asset: "timeCrystals", action: "read:any" },
];
module.exports = grantList;

As in the other examples, we have a mapping between roles, assets, and actions. Unlike the other examples, we are limited to the CRUD actions, and in our case, only read, update and delete apply. As you'll see below, we mapped our custom actions (gather, consume and destroy) to the CRUD actions (it's a bit odd, but that's what you get when you build your authorization library only around CRUD actions...)

We also specify that the sidekick role will be able to readAny of the megaSeeds, but we also limit the attributes that can be read. Specifically, we allow the sidekick to access all the attributes except for the id attribute.

We import the grant list to our main application file and initialize the AccessControl object:

const grantList = require("./grantlist");
const ac = new AccessControl(grantList);

In this case, instead of explicitly declaring all the roles and permissions, we can extend one role with another:

ac.grant("evilGenius").extend("sidekick");

The hasPermission implementation is a bit different than the other libraries we reviewed so far.

const hasPermission = (action) => {
return (req, res, next) => {
const { user } = req.body;
const { asset } = req.params;
const userRoles = resolveUserRoles(user);
const allowed = userRoles.reduce((perms, role) => {
let permissions;
switch (action) {
case "gather":
permissions = ac.can(role).readAny(asset);
if (permissions.granted) {
perms = perms.concat(permissions);
}
break;
case "consume":
permissions = ac.can(role).updateAny(asset);
if (permissions.granted) {
perms = perms.concat(permissions);
}
break;
case "destroy":
permissions = ac.can(role).deleteAny(asset);
if (permissions.granted) {
perms = perms.concat(permissions);
}
break;
}
return perms;
}, []);
if (allowed.length) {
const result = allowed.map((perm) => {
const data = assets[asset];
return {
data: perm.filter(data),
asRole: perm._.role,
};
});
res.locals = result;
next();
} else {
res.status(403).send("Forbidden");
}
};
};

In this code snippet, we switch over the action based on the CRUD verb associated with it. We then iterate over the userRoles array and collect the permissions for each role.

After collecting all the permissions, we iterate over them again and “fetch” any data the user has access to from a mock store (assets).

const assets = {
megaSeeds: {
id: "megaSeeds",
content: "This is asset 1",
},
timeCrystals: {
id: "timeCrystals",
content: "This is asset 2",
},
};

We then use the perm.filter method to filter the data such that only the allowed attributes are passed to the route function.

In this example, when we test the evilGenius user with the action gather on megaSeeds we'll get the following result:

[
{
"data": {
"content": "Mega Seeds grow on Mega Trees"
},
"asRole": "clone"
},
{
"data": {
"id": "megaSeeds",
"content": "Mega Seeds grow on Mega Trees"
},
"asRole": "evilGenius"
}
]

Based on the grants definition above, the clone is not allowed to see the id attribute, but the evilGenius is allowed to see all the attributes.

Click here to view the full Access-Control implementation.

Aserto

There are a couple of additional key differences that sets Aserto apart from the other libraries we’ve reviewed so far.

  • Policy as Code — What we’ve seen in the examples so far could be grouped into an approach called “Policy as Data”, where the policy itself is reasoned through the data that represents it. Aserto uses a different approach where the policy is expressed and reasoned about as code.
  • Reasoning about the policy as code makes the policy a lot more natural to write and maintained by developers. It takes away the need to traverse and reason about complex graphs or data structures. It also allows for more flexibility in the policy definition, as policies can be defined in a much more declarative way. Instead of convoluted data structures, developers can write the policy in a way that is a lot more concise and readable — and changes to the policy are made by changing the rules of the policy as opposed to rows in a database.
  • Users as First-Class Citizens — With Aserto, users and their roles are first-class citizens. Aserto provides a directory of users and their roles which is continuously synchronized with the Aserto authorizer. This allows Aserto to reason about users and their roles as part of the policy itself — without requiring role resolution as an additional external step (This is why the users.json file and the resolveUserRoles function are not going to be required as you'll see below). Having the role resolution as part of the application comes with its own set of risks - and the directory eliminates the risk of contaminating the decision engine with untrustworthy data.

Setting up Aserto

Add The Acmecorp IDP

From the drop-down menu, select “Acmecorp”

Name the provider acmecorp and give it a description.

Finally, click “Add connection”:

Create a Policy

First, select your source code provider. If you haven’t set one up already, you can do so by clicking the “Add a new source code connection” in the dropdown. This will bring up a modal for adding a connection to a provider. Note that Aserto supports GitHub as a source code provider, but allows you to connect to it either over an OAuth2 flow, or using a Personal Access Token (PAT).

After you’re done connecting your Github account (or if you previously connected it), select “github” as your Source code provider.

Next, you’ll be asked to select an organization & repo. Select the “New (using template)” radio button, and select the “policy-template” template.

Name your policy repo “policy-node-rbac” and click “Create repo”.

Name your policy “policy-node-rbac”:

And finally, click “Add policy”:

Head to Github and open the newly created repository, and clone it.

git clone https://github.com/[your-organization]/policy-node-rbac

Lastly, delete the policy hello.rego under the /src/policies folder.

Aserto Policies

package noderbac.POST.api.__assetdefault allowed = falseallowed {
input.user.attributes.roles[_] == "clone"
input.resource.asset == data.assets[_]
}
allowed {
input.user.attributes.roles[_] == "sidekick"
input.resource.asset == data.assets[_]
}
allowed {
input.user.attributes.roles[_] == "evilGenius"
input.resource.asset == data.assets[_]
}

The first line of the policy defines the name of the package, and it matches the route it will protect. Next, we define that by default, the allowed decision will be false - this means we're defaulting to a closed system, where access has to be explicitly granted.

The next three clauses will evaluate the allowed decision based on the user's roles and the asset they're trying to access. For example, the first line in the first clause will check if the user has the role of clone assigned to them. The user roles are automatically resolved by Aserto based on the user's identity.

The second line of the first clause will check whether the asset the user is trying to access is listed in the data.assets object, which is part of the policy. The asset is passed to the policy as part of the resource context (more details below). A policy can have a data file attached that could be used in the context of the policy. In our case, it includes the list of assets users can access. Under the /src folder, create a file called data.json and paste the following code into it:

{
"assets": ["megaSeeds", "timeCrystals"]
}

Using a separate data file to define the protected assets, we don’t have to explicitly define them in the policy (as we had to do in the previous examples).

The policies for /api/edit/:asset and /api/delete/:asset are identical to the ones for /api/read/:asset, except that the roles associated with each are different.

We’ll create a file under /src/policies called noderbac.PUT.api.__asset.rego and paste the following code into it:

package noderbac.PUT.api.__assetdefault allowed = falseallowed {
input.user.attributes.roles[_] == "sidekick"
input.resource.asset == data.assets[_]
}
allowed {
input.user.attributes.roles[_] == "evilGenius"
input.resource.asset == data.assets[_]
}

Next, we’ll create a file under /src/policies called noderbac.DELETE.api.__asset.rego and paste the following code into it:

package noderbac.DELETE.api.__assetdefault allowed = falseallowed {
input.user.attributes.roles[_] == "evilGenius"
input.resource.asset == data.assets[_]
}

As you can see, the policy for the consume route is allowing both sidekick and evilGenius access, while the policy for the destroy route is allowing access only to evilGenius.

Lastly, we’ll update the .manifest file to include the reference to the data in our data.json file. Update the /src/manifest.json file to include the following:

{
"roots": ["noderbac", "assets"]
}

To deploy the new policy, we’ll just commit, tag, and push it to the repo we created:

git add .
git commit -m "Created RBAC Policy"
git push
git tag v0.0.1
git push --tags

Application implementation

const { is } = require("express-jwt-aserto");const options = {
authorizerServiceUrl: "https://authorizer.prod.aserto.com",
policyId: process.env.POLICY_ID,
authorizerApiKey: process.env.AUTHORIZER_API_KEY,
tenantId: process.env.TENANT_ID,
policyRoot: process.env.POLICY_ROOT,
useAuthorizationHeader: false,
};
const hasPermission = (action) => {
return async (req, res, next) => {
const { user } = req.body;
const { asset } = req.params;
req.user = { sub: user.id };
const allowed = await is("allowed", req, options, false, { asset });
allowed ? next() : res.status(403).send("Forbidden").end();
};
};

Here we pass the user’s id as part of the req object. In production use cases, the req.user object would be populated after the user's authentication has been completed. The is function is going to return the allowed decision for the given route (encapsulated in the req object), for the asset we specify in the resource context.

The configuration passed to the is function (in the options object) requires that we create a .env file in the root of the project, and populate some environment variables from the Aserto console, on the Policy Details page:

Copy the Policy ID, Authorizer API Key, and Tenant ID to the .env file:

POLICY_ID=<Your Policy ID>
AUTHORIZER_API_KEY=<Your Authorizer API Key>
TENANT_ID=<Your Tenant ID>
POLICY_ROOT=noderbac

To run the example, run the following commands in the aserto directory:

yarn install
yarn start

Finally, you can test the application by running the same curl commands as before:

curl --location --request <HTTP Verb> 'http://localhost:8080/api/<asset>' \
--header 'Content-Type: application/json' \
--data-raw '{
"user": {
"id": "rick@the-citadel.com"
}
}'

Summary

While it might seem easier to use a library to implement RBAC in your Node.JS application, it is important to consider the lifecycle of the application and how it’ll grow. How will new users and roles be added? What would be the implications of changing the authorization policy? How will we reason about the authorization policy when it gets to be more complex?

Using a library means that you assume ownership of the authorization component — which requires time and effort to build and maintain. By using a service such as Aserto you can offload the responsibility of managing the authorization flow — without sacrificing the performance or availability of your application.

--

--

Welcome to modern authorization.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store