Building RBAC in Node

Feb 3rd, 2022

Roie Schwaber-Cohen avatar

Roie Schwaber-Cohen

Authorization  |  

Integration

node-rbac

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.

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.

too many

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

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 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

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 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!

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:

  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

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 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 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.

show-me-what-you-got

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 single 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 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

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 a connection" button.

add-a-connection

From the drop-down menu, select "Acmecorp"

add-acmecorp

Name the provider acmecorp and give it a description.

Finally, click “Add connection”:

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).

connect github

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

select github

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

create policy repo

Name your policy repo "policy-node-rbac" and click "Create repo".

create repo button

Name your policy "policy-node-rbac":

name policy

And finally, click "Add policy":

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:

policy details

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.

Roie Schwaber-Cohen avatar

Roie Schwaber-Cohen

Developer Advocate