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:
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.
For every resource block, we also need to register the type with Oso:
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:
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:
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);
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:
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:
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
.
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));
}
}
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:
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
:
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:
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 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:
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:
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:
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.