Introduction
Role Based Access Control (RBAC) is an access control pattern that governs the way users access applications based on the roles they are assigned. Roles are essentially groupings of permissions to perform operations on particular resources. Instead of assigning numerous permissions to each user, RBAC allows users to be assigned a role that grants them access to a set of resources. For example, a role could be something like evilGenius
, or a sidekick
. A sidekick
like Morty Smith for example could have the permission to gather mega seeds
, and an evilGenius
like Rick would be able to create
a microverse
.
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
- You'll need a basic understanding of Javascript and Node.js to follow this post.
- 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
The code examples shown below can be found in this repository. To run each of them, navigate to the corresponding directory and run yarn install
followed by yarn start
.
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 calledresolveUserRole
which, given a user will resolve their role. This function is shared by all of the examples and is found inutils.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
To test the application, we'll make a set of requests to the routes and check the responses:
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 togather
megaSeeds
andtimeCrystals
- Morty (AKA the
sidekick
): Should be only able togather
andconsume
megaSeeds
andtimeCrystals
- Rick (AKA the
evilGenius
): Should be able togather
,consume
anddestroy
onlymegaSeeds
andtimeCrystals
.
Let's go get those mega seeds!
Vanilla Node.js
To set the scene, we start with the most simplistic way of enforcing roles in a Node.js application. In this example, we're going to use a JSON file (roles.json
) that will map specific roles to actions they may perform, and assets they may perform those actions on:
{
"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:
- Iterate over each user role
- Check the existence of the user's given
role
in theroles
object - 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. - 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
Casbin is a powerful and efficient open-source access control library. It has SDKs in many languages, including Javascript, Go, Rust, Python, and more. It provides support for enforcing authorization based on various access control models: from a classic "subject-object-action" model, through RBAC and ABAC models to fully customizable models. It has support for many adapters for policy storage.
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 withp
) for each role (clone
,sidekick
, andevilGenius
)The
role_definition
section is specific to 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 forsidekick
andevilGenius
, wheresidekick
inherits fromclone
andevilGenius
inherits fromsidekick
(which means theevilGenius
will also have theclone
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 matches the policy parameters and that the roler.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 CASL library is an isomorphic authorization that's designed to be incrementally adoptable. Its aim is to make it easy to share permissions across UI components, API services, and database queries. CASL doesn't have the concept of a role - it can only assign a set of permission to a user. It is the responsibility of the developer to handle to the assignment of the proper permissions to a user based on their assigned roles. Instead, CASL permissions are defined as tuples of "action", "subject", "conditions" and optionally "fields".
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 rbac
library provides a simple interface for RBAC authorization. It provides an asynchronous interface for the storage of the policy and supports hierarchical roles.
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
The Access-Control project offers a "Chainable, friendly API" with hierarchical role inheritance. It allows developers to define roles using a single definition file or using a chain of .can
calls. It only supports the CRUD action verbs, with two ownership modifiers: any
and own
.
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
Aserto takes a fundamentally different approach to authorization than all of the examples we've seen above. First and foremost - Aserto is an authorization service, with an SDK that allows easy integration into the application. Aserto can be deployed as a sidecar to your application - which guarantees maximum availability as well as a single-digit millisecond response time for authorization decisions.
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 or theresolveUserRoles
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
Aserto offers a console for managing policies - to create a new policy, you'll need to sign in. If you don't already have an Aserto account, you can create one here.
Add The Acmecorp IDP
To simulate the behavior of a user directory, we'll add the "Acmecorp IDP", which includes mock users that will be added to our directory. Head on to the Aserto Console, select the "Connections" tab and click the "Add Connection" button.
From the drop-down menu, select "Acmecorp"
Name the provider acmecorp
and give it a description.
Finally click “Add connection”:
Create a Policy
Click here to create a new 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
Let's take a look at how policies are defined in Aserto. For the use case we presented, we'll need a policy for every route the application exposes. Let's start by creating the policy /api/read/:asset
route. Under /src/policies
, we'll create a file called noderbac.POST.api.read.__asset.rego
, and paste the following code into it:
package noderbac.POST.api.__asset
default allowed = false
allowed {
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.__asset
default allowed = false
allowed {
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.__asset
default allowed = false
allowed {
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
The hasPermission
function implementation is mostly similar, except that we're not going to resolve the user roles, since Aserto will do that for us:
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
In the post, we reviewed multiple ways of adding RBAC to your application. We've seen that in most cases, users are not considered a first-class citizen concept in the authorization offering and that the process of role resolution is left to the developer, and ends up as part of the application itself, which introduces many risks. We've also seen that most solutions take the "Policy-as-Data" approach as opposed to the "Policy-as-Code" approach.
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.
Top comments (0)