Adding Authorization to a Node.js App – Beyond Role-Based Access Control

Sam Scott

I regularly hold office hours where I speak with users about problems they're coming up against in auth. Following a recent conversation, the engineer I spoke with and I built a demo app using NestJS (a JavaScript web framework) and oso to mock up how access control would work in his company's core application.

I thought I'd share the demo app, which uses several common patterns – e.g., roles, hierarchies, and attributes – as well as how to make oso work in a web framework. In this post, we will show:

  • How to use the oso Node.js library
  • How to represent common access control patterns in using Polar, the oso policy language
  • What a declarative approach to policy code looks like

Install and run the demo

The demo app is available on GitHub and contains a step-by-step tutorial that explains how the app works and provides the details of the oso implementation.

The app is a document management system. Users can create, read, update, and delete Documents if they have the proper authorization. Documents are grouped into Projects. Users can be members of projects, or can even be Guests to the system (i.e., unauthenticated altogether).

The authorization spec is written using Polar and is organized in two files 1) roles.polar, which defines roles and their relationships; and 2) permissions.polar, which defines the rules by which users acquire roles and the access privileges each role is granted.

To see the specifics of how we implemented the demo app and used the oso Node.js library, I would encourage you to run through the tutorial and crack open the code. For the rest of this post, let's focus on how we implement the demo's authorization spec in Polar.

Roles and authentication in the app code

The demo app has three roles: "owner", "member", and "guest".

The app's DocumentController uses a class decorator to put a BasicAuthGuard and OsoGuard on all of its request-handling methods:

@UseGuards(BasicAuthGuard, OsoGuard, OsoInstance)
@Controller('document')
@Resource('Document')
export class DocumentController {

The authentication guard tries to resolve a User object based on credentials in the Request and, if successful, puts that authenticated User object in Request.user. Otherwise, the user defaults to being a Guest user.

Roles and authorization in oso

Authentication-based access control is a good first line of defense, but we'll use oso for finer-grained authorization via the class-scoped OsoInstance, the method-scoped OsoGuard, and the parameter-scoped @Authorize decorations used in the DocumentController.

For example, the @Authorize parameter provides an authorize() function used inside the controller methods to authorize access to a particular resource. One such usage is in the findOne method, to determine whether the target document can be read by the current user.

@Get(':id')
async findOne(@Param("id") id: string, @Authorize() authorize: any): ... {
  const document = await this.documentService.findOne(Number.parseInt(id));
  await authorize(document);
  return document ? new FindDocumentDto(document) : undefined;
}

Role definitions

Roles are defined in roles.polar. If there is a Guest object in Request.user, the visitor is granted the "guest" role:

# The "Guest" actor has "guest" role
role(_guest: Guest, "guest", _document: Document);

Here, a User is granted the "owner" role for a Document if their user id matches the Document's ownerId:

role(user: User, "owner", document: Document) if
    document.ownerId = user.id;

Users can also be project owners/members, and a User's role for a Document is inherited from their role for the Project:

## Document roles from project roles
# User has a role for an document if they have the same
# role for the project
role(user: User, role, document: Document) if
    role(user, role, document.project);

Here, further roles are inherited based on earlier role definitions:

## Role inheritance. Owner > Member > Guest

# User is a member of a Document if they are an owner of that Document
role(user: User, "member", document: Document) if
    role(user, "owner", document);

Role-based access control

Using role-based access control (RBAC), we allow or deny access to read or write documents based on a user's role. The following rules express role-based privileges:

Allow any authenticated user to create a document:

allow(_user: User, "create", "Document");

Allow a user to read a document if they are a member of that document:

allow(user: User, "read", document: Document) if
    role(user, "member", document);

Allow a user to delete a document if they are the owner of that document:

allow(user: User, "delete", document: Document) if
    role(user, "owner", document);

Why role-based access control isn't enough

For some of the rules we want to express, role-based access works well. But it doesn't always let us express all the scenarios we need in our app.

For example, the engineer I met with during office hours wanted to be able give customers the ability to restrict view access to documents by setting a ‘members-only' flag. That is, a document would be visible to everyone unless it had that flag, in which case only members could view it..

Here, statically assigning roles to particular users isn't enough. We need a way of dynamically checking access based on attributes of the user and the resource (i.e., the document) in question. This is attribute-based access control (ABAC).

Without changing any application code, we can add a three-line policy to express this condition.

The below rule says that any Guest can read a Document if the document does not have the 'members_only' condition:

allow(user: Guest, "read", document: Document) if
    role(user, "guest", document)
    and not members_only(document);

Actually, this isn't the first time we've used attributes. We used them in prior examples to define roles -- i.e. we identified the "owner" role of a document based on matching the user.ownerId and document.id attributes. But in this most recent case, we're going a step further and using the attributes to make finer-grained access control decisions, which is a big step up over what this might look like if we were to try to do it in the application:

function authorize(user: User, project: Project, document: Document, action: Action): boolean {
  const role: Role = getRoleForDocument(user, document);
  switch (action) {
    case Action.Create:
      if (role == Role.Guest) {
        return false
      } else if (role == Role.Owner || role == Role.Admin) {
        return true
      } else if (project == null) {
        return false
      } else if (project.getMembers().has(user.id)) {
        return true
      }
      break;
    case Action.Read:
      return (role == Role.Guest && !document.membersOnly) || role != Role.Guest;
    case Action.Update:
      return role != Role.Guest;
    case Action.Delete:
      return role == Role.Owner || role == Role.Admin;
    default:
      return false
  }
}

While a toy example like this might be reasonably manageable in app code, it's not hard to imagine the logic getting significantly messier as new role and action relationships bloom over the life of the application.

Extensibility and separation of concerns

One advantage of separating authorization concerns from business logic is being able to modify or extend authorization policy without changing any application code. For example, let's say we want to add the notion of an "organization" where members of the organization have the same privileges as members of the document or project. In two lines of Polar, we can extend the role a user has in an organization and all of its privileges to documents:

# User has a role for a project if they have the same
# role for the organization
role(user: User, role, project: Project) if
    role(user, role, project.organization());

And since document roles already inherit from project roles, we are done!

New roles, new rules

As an app evolves, and its entity relationships grow more complex, extensibility and a clean abstraction for this kind of logic becomes especially useful.

If, for example, we need to add support for external parties to have limited access to documents, we don't need to change any controller logic or examine if statements in our application codebase. We can implement it by synthesizing a new "external" role in our policy code.

role(user: User, "external", project: Project) if
    project.isMember(user.id)
    and user.isExternal

allow(user, action, document: Document) if
    role(user, "external", document)
    and document_action(action, "read");

In two stanzas, we've created a new role type and only granted read access to Document, as suitable for an external party like an SEO consultant.

Conclusion

We now have 10 rules: to check for whether a User is a guest, owns a document, or owns a project; to give certain users the ability to read, edit and delete documents; to give external parties a way to access documents; and more. These are declarative rules that the engineer can express and add as they come up, without having to rationalize or explicitly describe the combinations of the various policies and their potential outcomes. Under the surface, oso searches through the declared rules using inferences and backtracking to find whether there exists a set of satisfied conditions to evaluate to true.

This is one way that declarative approaches can be especially useful – you just express what you want the outcome to be.

Here are some useful links if you want to learn more about oso:

Get updates from oso.

We won't spam you. Ever.