Role-Based Access Control (RBAC) in Node.js

Role-Based Access Control (RBAC) in Node.js

Role-based access control (RBAC) is so ubiquitous that Oso provides syntax for modeling RBAC. This syntax makes it easy to create a role-based authorization policy with roles and permissions – for example, declaring that the reader role on a repository allows a user to read from that repository. In this guide, we’ll walk through building an RBAC policy for GitCloud (opens in a new tab), a sample version-control application.

You can follow along with the steps in this guide by creating a free Oso Cloud account (opens in a new tab).

Declare application types as actors and resources

Oso makes authorization decisions by determining if an actor can perform an action on a resource:

  • Actor: who is performing the action? User("Bill")
  • Action: what are they trying to do? "read"
  • Resource: what are they doing it to? Repository("tps-reports")

The first step of building an RBAC policy is telling Oso which application types are actors and which are resources. Our example app has a pair of resource types that we want to control access to: Organization and Repository. We declare both as resources in the Oso Cloud Rules Editor (opens in a new tab) as follows:


resource Organization {}
resource Repository {}

Our app also has a User type that will be our lone type of actor:


actor User {}

This piece of syntax is called a resource block, and it performs two functions: it identifies the type as an actor or a resource, and it provides a centralized place to declare roles and permissions for that particular type.

Declare roles and permissions

In GitCloud, users can be granted permission to read jobs and manage (e.g. create, cancel) jobs for a repository. Users can also be assigned roles on organizations and repositories, such as the admin role for an organization or the maintainer role for a repository.

Inside the curly braces of each resource block, we declare the roles and permissions for that resource:


actor User { }
resource Organization {
roles = ["admin", "member"];
}
resource Repository {
permissions = [
"read",
"manage_jobs"
];
roles = ["reader", "maintainer"];
}

Grant permissions to roles

Next, we’re going to write shorthand rules that grant permissions to roles. For example, if we grant the manage_jobs permission to the maintainer role in the Repository resource block, then a user who’s been assigned the maintainer role for a particular repository can manage jobs on that repository. Here’s our Repository resource block with a few shorthand rules added:


resource Repository {
permissions = [
"read",
"manage_jobs"
];
roles = ["reader", "maintainer"];
# reader permissions
"read" if "reader";
# maintainer permissions
"manage_jobs" if "maintainer";
}

Shorthand rules expand to regular Polar rules when a policy is loaded. The "manage_jobs" if "maintainer" shorthand rule above expands to:


has_permission(actor: Actor, "manage_jobs", repository: Repository) if
has_role(actor, "maintainer", repository);

Note:

Instances of our application's User type will match the Actor parameter because of our actor User {} resource block declaration.

Assign roles to other roles

All of the shorthand rules we’ve written so far have been in the <permission> if <role> form, but we can also write <role1> if <role2> rules. This type of rule is great for situations where you want to express that <role2> should be granted every permission you’ve granted to <role1>.

In the previous snippet, the permissions granted to the maintainer role are a superset of those granted to the reader role. If we replace the existing "read" if "maintainer" rule with "reader" if "maintainer", the "maintainer" role still grants the "read" permission:


resource Repository {
permissions = ["read", "manage_jobs"];
roles = ["reader", "maintainer"];
# An actor has the "read" permission if they have the "reader" role.
"read" if "reader";
# An actor has the "manage_jobs" permission if they have the "maintainer" role.
"manage_jobs" if "maintainer";
# An actor has the "reader" role if they have the "maintainer" role.
"reader" if "maintainer";
}

In addition, any permissions we grant the reader role in the future will automatically propagate to the "maintainer" role.

Storing facts in Oso Cloud

An Oso policy contains authorization logic, but the application remains in control of all authorization data. For example, the logic that the maintainer role on a repository grants the manage_jobs permission lives in the policy, but the policy doesn’t manage the data that defines which users have been assigned the maintainer role on Repository("Acme App"). That data is represented as facts in Oso Cloud.

To create facts in Oso Cloud from your node.js application, first install the oso-cloud npm package (opens in a new tab).


npm install oso-cloud

Next, get an API key (opens in a new tab) from Oso Cloud. The oso-cloud npm package exports an Oso class that you can use to instantiate a new Oso Cloud client as follows.


const { Oso } = require("oso-cloud");
const assert = require("assert");
const apiKey = process.env.OSO_CLOUD_API_KEY;
assert.ok(apiKey, "Must set OSO_CLOUD_API_KEY environment variable");
const oso = new Oso("https://cloud.osohq.com", apiKey);

Now, use the oso.tell() function to create a new fact. The following function call tells Oso Cloud that the user "Bill" has role "reader" on repository "tps-reports".


await oso.tell(
"has_role", // Fact name
{ type: "User", id: "Bill" }, // Actor
"reader", // Role
{ type: "Repository", id: "tps-reports" } // Resource
);

Authorizing users with Oso Cloud

Once you have added the above fact, you can use the oso.authorize() function to check whether a user can take certain actions based on their roles as follows.


let authorized = await oso.authorize({ type: "User", id: "Bill" }, "read", {
type: "Repository",
id: "tps-reports",
});
console.log(authorized); // true
authorized = await oso.authorize({ type: "User", id: "Bill" }, "manage_jobs", {
type: "Repository",
id: "tps-reports",
});
console.log(authorized); // false

Inherit a role on a child resource from the parent

If you’ve used GitHub GitCloud before, you know that having a role on an organization grants certain roles and permissions on that organization’s repositories. For example, a user is granted the "reader" role on a repository if they’re assigned the "member" role on the repository’s parent organization. This is how you write that rule with Oso:


resource Organization {
roles = ["admin", "member"];
}
resource Repository {
permissions = [
"read",
"manage_jobs"
];
roles = ["reader", "maintainer"];
relations = { organization: Organization };
# ...
"reader" if "member" on "organization";
}

First, we declare that every Repository has an organization relation that references an Organization:


relations = { organization: Organization };

This is a dictionary where each key is the name of the relation and each value is the relation’s type.

Next, we add a has_relation fact to Oso Cloud.


await oso.tell(
"has_relation",
{ type: "Repository", id: "tps-reports" },
"organization",
{ type: "Organization", id: "initech" }
);

This says that the tps-reports repository is related to the initech organization. Next, we add a shorthand rule that involves the "reader" repository role, the "member" organization role, and the "organization" relation between the two resource types:


resource Repository {
permissions = ["read", "push"];
roles = ["reader", "maintainer"];
relations = { organization: Organization };
# ...
# An actor has the "reader" role on a Repository if they have the "member" role on its parent Organization.
"reader" if "member" on "organization";
}

Finally, we add a fact telling Oso Cloud that a user has a role on a given organizaton:


await oso.tell("has_role", { type: "User", id: "Michael" }, "member", {
type: "Organization",
id: "initech",
});

This says that the user Michael has the member role on the initech organization (which we defined as the parent of the tps-reports repository with the has_relation fact above). Once the has_relation and has_role facts are set up, Oso Cloud can resolve that user "Michael" should be able to read the tps-reports repository, but not manage_jobs.


let authorized = await oso.authorize({ type: "User", id: "Michael" }, "read", {
type: "Repository",
id: "tps-reports",
});
console.log(authorized); // true
authorized = await oso.authorize(
{ type: "User", id: "Michael" },
"manage_jobs",
{ type: "Repository", id: "tps-reports" }
);
console.log(authorized); // true

Baby got RBAC

Our complete policy looks like this:


actor User { }
resource Organization {
roles = ["admin", "member"];
}
resource Repository {
permissions = [
"read",
"manage_jobs"
];
roles = ["reader", "maintainer"];
relations = { organization: Organization };
"reader" if "member" on "organization";
"maintainer" if "admin" on "organization";
"reader" if "maintainer";
"read" if "reader";
"manage_jobs" if "maintainer";
}

It's easy to add authorization to your node.js application with Oso Cloud (opens in a new tab). You can model common application authorization patterns like RBAC in the Rules Editor (opens in a new tab) with concise rules that are easy to read. Then, you use the oso.tell() function to send authorization data from your application to Oso Cloud. With the policy and data in place, you implement enforcement by calling oso.authorize() to confirm that the given user has permission to perform the requested action.

If you'd like to learn more, including how to integrate Oso Cloud with other languages, check out our docs. You can also join us on Slack (opens in a new tab) to ask any questions, or if you'd like to share what you're building with the Oso Community.