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

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 "maintainer" role on a repository allows a user to "push" to that repository. In this guide, we’ll walk through building an RBAC policy for GitClub.

This guide assumes you’re building authorization in a monolith application. If you’re building with microservices, read about how to model roles using Oso Cloud.

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("Ariana")
  • Action: what are they trying to do? "push"
  • Resource: what are they doing it to? Repository("Acme App")

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 as follows:

main.polar
resource Organization {}

resource Repository {}

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

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

Note

For every resource block, we also need to register the type with Oso:

app.js
const oso = new Oso();

oso.registerClass(Organization); oso.registerClass(Repository); oso.registerClass(User); await oso.loadFiles(["main.polar"]);

Declare roles and permissions

In GitClub, users can perform actions such as "delete"-ing an organization or "push"-ing to a repository. Users can also be assigned roles for either type of resource, such as the "owner" 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:

main.polar
resource Organization {
  roles = ["owner"];
}

resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];
}

Grant permissions to roles

Next, we’re going to write shorthand rules that grant permissions to roles. For example, if we grant the "push" 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 "push" to that repository. Here’s our Repository resource block with a few shorthand rules added:

main.polar
resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];

  # An actor has the "read" permission if they have the "contributor" role.
  "read" if "contributor";
  # An actor has the "read" permission if they have the "maintainer" role.
  "read" if "maintainer";
  # An actor has the "push" permission if they have the "maintainer" role.
  "push" if "maintainer";
}

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

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

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

Grant 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 "contributor" role. If we replace the existing "read" if "maintainer" rule with "contributor" if "maintainer", the "maintainer" role still grants the "read" permission:

main.polar
resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];

  # An actor has the "read" permission if they have the "contributor" role.
  "read" if "contributor";
  # An actor has the "push" permission if they have the "maintainer" role.
  "push" if "maintainer";

  # An actor has the "contributor" role if they have the "maintainer" role.
  "contributor" if "maintainer";
}

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

Access role assignments stored in the application

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 "push" permission lives in the policy, but Oso doesn’t manage the data of which users have been assigned the "maintainer" role for Repository("Acme App"). That data stays in the application, and Oso asks the application for it as needed.

The main question Oso asks is: does User("Ariana") have the "maintainer" role on Repository("Acme App")? For Oso to be able to ask this question, we need to implement a has_role() rule in the policy:

main.polar
has_role(user: User, name: String, resource: Resource) if
  role in user.roles and
  role.name = name and
  role.resource = resource;

role in user.roles iterates over a user’s assigned roles and role.name = name and role.resource = resource succeeds if the user has been assigned the name role for resource.

Note
The body of this rule will vary according to the way roles are stored in your application. The data model for our GitClub example is as follows:
app.js
class Organization {
  constructor(name) {
    this.name = name;
  }
}

class Repository {
  constructor(name, organization) {
    this.name = name;
    this.organization = organization;
  }
}

class Role {
  constructor(name, resource) {
    this.name = name;
    this.resource = resource;
  }
}

class User {
  constructor(name) {
    this.name = name;
    this.roles = new Set();
  }

  assignRoleForResource(name, resource) {
    this.roles.add(new Role(name, resource));
  }
}
If, for example, repository roles and organization roles were stored separately instead of in a heterogeneous set, we might define a pair of `has_role()` rules, one for each role type:
has_role(user: User, name: String, repository: Repository) if
  role in user.repositoryRoles and
  role.name = name and
  role.repository = repository;

has_role(user: User, name: String, organization: Organization) if
  role in user.organizationRoles and
  role.name = name and
  role.organization = organization;
  

Our has_role() rule can check role assignments on repositories and organizations, but so far we’ve only talked about repository roles. Let’s change that and see how Oso can leverage parent-child relationships like the one between Repository and Organization to grant a role on a child resource to a role on the parent.

Inherit a role on a child resource from the parent

If you’ve used GitHub GitClub 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 "maintainer" role on a repository if they’re assigned the "owner" role on the repository’s parent organization. This is how you write that rule with Oso:

main.polar
resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];
  relations = { parent: Organization };

  # ...

  # An actor has the "maintainer" role if they have the "owner" role on the "parent" Organization.
  "maintainer" if "owner" on "parent";
}

has_relation(organization: Organization, "parent", repository: Repository) if
  organization = repository.organization;

First, we declare that every Repository has a "parent" relation that references an Organization:

main.polar
  relations = { parent: Organization };

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

Next, we write a has_relation() rule that tells Oso how to check if an organization has the "parent" relation with a repository:

main.polar
has_relation(organization: Organization, "parent", repository: Repository) if
  organization = repository.organization;

In this case, an organization is the "parent" of a repository if the repository’s organization field points to it.

Note

Note that the resource where we declared the relationship, Repository, is the third parameter and the related resource, Organization, is the first.

This ordering was chosen to mirror the ordering of the expanded forms for has_role() and has_permission(), where the resource for which the actor has the role or permission is the third argument:

has_role(actor: Actor,                   name: String, resource: Resource) if ...

has_permission(actor: Actor,             name: String, resource: Resource) if ...

has_relation(related_resource: Resource, name: String, resource: Resource) if ...

Finally, we add a shorthand rule that involves the "maintainer" repository role, the "owner" organization role, and the "parent" relation between the two resource types:

main.polar
resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];
  relations = { parent: Organization };

  # ...

  # An actor has the "maintainer" role if they have the "owner" role on the "parent" Organization.
  "maintainer" if "owner" on "parent";
}

Add an allow() rule

At this point, the policy is almost fully functional. All that’s left is adding an allow() rule:

main.polar
allow(actor, action, resource) if
  has_permission(actor, action, resource);

This is a typical allow() rule for a policy using resource blocks: an actor is allowed to perform an action on a resource if the actor has permission to perform the action on the resource.

This allow() rule serves as the entrypoint when we query our policy via Oso’s enforcement methods like Oso.authorize() :

await oso.authorize(new User("Ariana"), "push", new Repository("Acme App"));

Baby Got RBAC

Our complete policy looks like this:

main.polar
allow(actor, action, resource) if
  has_permission(actor, action, resource);

has_role(user: User, name: String, resource: Resource) if
  role in user.roles and
  role.name = name and
  role.resource = resource;

actor User {}

resource Organization {
  roles = ["owner"];
}

resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];
  relations = { parent: Organization };

  # An actor has the "read" permission if they have the "contributor" role.
  "read" if "contributor";
  # An actor has the "push" permission if they have the "maintainer" role.
  "push" if "maintainer";

  # An actor has the "contributor" role if they have the "maintainer" role.
  "contributor" if "maintainer";

  # An actor has the "maintainer" role if they have the "owner" role on the "parent" Organization.
  "maintainer" if "owner" on "parent";
}

has_relation(organization: Organization, "parent", repository: Repository) if
  organization = repository.organization;

If you’d like to play around with a more fully-featured version of this policy and application, check out the GitClub repository on GitHub.

If you’re building with microservices, read about how to model roles using Oso Cloud.

Connect with us on Slack

If you have any questions, or just want to talk something through, jump into Slack. An Oso engineer or one of the thousands of developers in the growing community will be happy to help.