Building RBAC in Node

Introduction

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

  • 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"
}
}'
  • 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.

Vanilla Node.js

{
"clone": {
"gather": ["megaSeeds", "timeCrystals"]
},
"sidekick": {
"gather": ["megaSeeds", "timeCrystals"],
"consume": ["megaSeeds", "timeCrystals"]
},
"evilGenius": {
"gather": ["megaSeeds", "timeCrystals"],
"consume": ["megaSeeds", "timeCrystals"],
"destroy": ["megaSeeds", "timeCrystals"]
}
}
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();
};
};
  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.

Node-Casbin

[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))
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.
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();
};
};

CASL

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);
}
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();
}
};
};

RBAC

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"],
},
});
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();
};
};

Access-Control

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;
const grantList = require("./grantlist");
const ac = new AccessControl(grantList);
ac.grant("evilGenius").extend("sidekick");
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");
}
};
};
const assets = {
megaSeeds: {
id: "megaSeeds",
content: "This is asset 1",
},
timeCrystals: {
id: "timeCrystals",
content: "This is asset 2",
},
};
[
{
"data": {
"content": "Mega Seeds grow on Mega Trees"
},
"asRole": "clone"
},
{
"data": {
"id": "megaSeeds",
"content": "Mega Seeds grow on Mega Trees"
},
"asRole": "evilGenius"
}
]

Aserto

  • 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

Create a Policy

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

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[_]
}
{
"assets": ["megaSeeds", "timeCrystals"]
}
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[_]
}
package noderbac.DELETE.api.__assetdefault allowed = falseallowed {
input.user.attributes.roles[_] == "evilGenius"
input.resource.asset == data.assets[_]
}
{
"roots": ["noderbac", "assets"]
}
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();
};
};
POLICY_ID=<Your Policy ID>
AUTHORIZER_API_KEY=<Your Authorizer API Key>
TENANT_ID=<Your Tenant ID>
POLICY_ROOT=noderbac
yarn install
yarn start
curl --location --request <HTTP Verb> 'http://localhost:8080/api/<asset>' \
--header 'Content-Type: application/json' \
--data-raw '{
"user": {
"id": "rick@the-citadel.com"
}
}'

Summary

--

--

Welcome to modern authorization.

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