How to Implement RBAC authorization in Nest.JS

Published by Rene Pot on March 20, 2024
image

NestJS is a great framework for building server-side applications in NodeJS. It has built-in TypeScript support (but doesn't force you to use it) and allows for quick development.

Bundle this with Cerbos, and you got your Authorization hooked up easily. There are several ways of integrating Cerbos within NestJS and we're going to look at a few of them in this article. Whichever solution you end up using, always keep performance in mind! If you don't have to fetch any data, then don't! Keep this in mind when exploring these options and see how they apply to your project.

Setting up the Demo Project

I've created a NestJS Cerbos demo project for you to use to see how Cerbos can be used within NestJS. In this demo project there are Cerbos policies included, as well as two different types of integration. One global Guard, and one resource-specific Interceptor.

To set up this project, first clone the project from Github. Then, in the root of the project, type npm install in the terminal to install any dependencies of the project.

After the installation is done, you can run your project using npm run start:devcerbos. This will kick off two different applications.

The first one is a very basic Cerbos installation using Docker. So if you already have one running on Docker, now is the time to first shut that down. And of course, if you don't have Docker running on your machine, install it and boot it up!

The second one is the NestJS application itself. The NestJS application is a slightly modified example project you can create using the NestJS CLI. Take a look at the NestJS getting starting documentation to see how to create a basic NestJS application.

Let's take a deeper look into the commands that are fired before we dive into the workings of the project.

The command npm run start:devcerbos runs this under the hood: npm run cerbos:start && npm run start:dev. As you can see it initiates two different scripts that are defined in package.json. First, it boots up the script cerbos:start, which actually is docker run --rm --name cerbos -d -v $(pwd)/cerbos:/policies -p 3592:3592 -p 3593:3593 ghcr.io/cerbos/cerbos:0.19.0. This command is only a slight modification of the command you'll encounter in the Cerbos Quickstart Guide. It will launch a Cerbos Docker container with basic configuration, and will use the policies defined within the same repository as our example project, nested in the /cerbos/policies directory.

The second command in the call is npm run start:dev. This is the predefined command by NestJS to run the NestJS instance, and have file monitoring to automatically restart the server as soon as you change anything.

Once this is all done, you should be able to do a GET on localhost:3000/album/1 using Postman or any other tool you use to test API calls. Make sure to set the Authorization header with the value user to test a hardcoded authentication/role.

Using a Guard

Now that you have the server(s) running, it's time to dive into the workings of the setup. First, we're looking into Guards.

NestJS has a feature called Guards, which, as NestJS documentation says: "determine whether a given request will be handled by the route handler or not". Sounds familiar? This sounds exactly where we can inject Cerbos.

Guards are called before any of the endpoint logic, or Controllers are being called. This means it's the perfect place to reject a call purely on the Authorization you've got set up. Take JWT for example, within the Payload section of the JWT token you can store an userId or role, and based on that you can determine whether or not the user is allowed to do a specific call.

Some examples of what you can implement using Guards:

  • Disallow POST calls on any endpoint
  • Disallow updating users if they're not the user in question or an admin
  • Disallow a GET on user profiles when not authenticated

These are, of course, just a few examples. Basically, anything that doesn't require you to do any data interaction would be great in a guard.

As an example, I've added a Global Guard to the Demo NestJS project that checks for any resource if public viewing is allowed. This example Guard is, by design, very simplistic, but should give a good idea of how to inject Cerbos into your NestJS project. In a real-life project, you probably will set the proper role here based on the headers you're receiving.

On top of that, a Global Guard will be triggered for every call done to your NestJS application. Chances are, you're not going to want this, and a better approach would be to use a Guard for specific controllers.

But let's go back to the Guard I've specified as a global Guard. You can see the inclusion of it within /src/main.ts

import { CerbosAuthGuard } from './app.cerbos.guard';

app.useGlobalGuards(new CerbosAuthGuard());

That's all you need to do to include a Guard globally. Now let's have a look at the guard, which is located in /src/app.cerbos.guard.ts.

On top of the file, we'll include @cerbos/grpc, and initiate a connection to the server.

First, we're taking apart the request to extract the useful information needed to make sure the request can be approved. Such as the URL that is requested, the method used, and the role of the user, which we end up not using (but you probably should!).

In the end, this is all the logic there is in our Guard:

const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const resource = request.url.match(/[^/]+/g);

const isAllowed = cerbos.isAllowed({
  principal: { id: 'public', roles: ['user'] },
  resource: {
    kind: `${resource[0]}:object`,
    id: resource[1],
  },
  action: ACTIONS[request.method],
});

return isAllowed;

For the purpose of the demo, the principal id is hardcoded as "public", and I've added a role in the Cerbos policies for this. You can find the roles in /cerbos/policies/derived_roles_common.yaml.

- name: public
  parentRoles: ["user"]
  condition:
    match:
      expr: request.principal.id == "public"

And finally, a policy for the Album controller is created in /cerbos/policies/resource_album.yaml, which includes both the public and the owner roles.

---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: "default"
  importDerivedRoles:
    - common_roles
  resource: "album:object"
  rules:
    - actions: ['*']
      effect: EFFECT_ALLOW
      derivedRoles:
        - public
        - owner

Using Data in a Guard

Because a Guard is called before your controller logic, you don't yet have access to the response data. However, as many Cerbos policies focus on ownership of the data as a reason to approve or reject an API call, you will need access to said data.

You can achieve this in several ways. One way would be to use an Interceptor, which we'll talk about later in this article, however, this has the downside that your controller code has already been executed, which might be unnecessary and impact the performance of your application as it will do more than needed.

Another solution, which is the recommended approach, is to fetch only the data you need to verify if the request can be allowed. Take a look at the defined owner role in the Cerbos Policies.

- name: owner
  parentRoles: ["user"]
  condition:
    match:
      expr: request.resource.attr.owner == request.principal.id

In here, we're comparing the principal.id with the resource.attr.owner property. The latter is the Id of the owner of the data the user is trying to fetch. To be able to verify this within a guard, all you need to do is fetch the ownerId from your datasource, and send that to Cerbos. This will have minimal impact on your performance as the query will be very light, and will prevent your controller code from being executed, which can be much more complex than just a simple database query. Of course, this heavily depends on your application so make sure you pick what suits your application best.

Using an Interceptor

Lastly, we're going to be looking at the Interceptor method of NestJS. An Interceptor is called before your controller logic is executed, but can also be used to intercept the response before it is going back to the user. A very basic Interceptor looks like this.

@Injectable()
export class CerbosInterceptor implements NestInterceptor {
  intercept(host: ExecutionContext, next: CallHandler): Observable<any> {
    
    console.log('before controller code');

    return next.handle().pipe(
      map((data) => {
        console.log('after controller code');
      }),
    );
  }
}

As you can see, the Interceptor is called before the controller code but has a way to put itself in the response as well. This allows you to put logic before and after the controller code. For Cerbos we're only interested in after the controller is executed as we've covered the before with a Guard (as you should!). Much of the Guard code can be reused, such as getting the URL, the role and the method.

You can find the complete interceptor code in /src/album/album.cerbos.interceptor.ts. Here we're just looking at the Cerbos call.

const cerbosRequest = {
    principal: { id: role, roles: ['user'] },
    action: 'view:owner',
    resource: {
        kind: `${resource[0]}:object`,
        id: data.id,
        attributes: { public: true, owner: data.owner },
    },
};

As you can see, this time we are using the role provided in the header, and we're also using the data.owner property as an attribute to verify if the user is actually the owner of the requested data.

This time, we're getting our data from /src/album/album.controller.ts which we can use for Cerbos.

Now the fun part is, the map function that I showed you earlier, can also have an async function. And considering Cerbos returns a promise, we can actually await for the result. So taking our CerbosRequest from above, we'll end up with something like this

const cerbosResult = await cerbos.isAllowed(cerbosRequest);
if (cerbosResult === false) throw new ForbiddenException();
return data;

But don't forget to make the function async, otherwise, the code above will throw an error. You can do this by changing the line with map in it to map(async (data) => {.

ForbiddenException is a @nestjs/common helper function to throw a rejection instead of returning data. This way, when Cerbos says no, you can prevent any data from being returned.

Conclusion

NestJS is very powerful in what it can offer in terms of integrations. We've touched upon Guards and Interceptors here, but only from a very high-level overview. You can go as deep (or shallow) as you see fit.

Cerbos integrates nicely and almost effortlessly with NestJS using these Guards and Interceptors.

However, there's one thing you need to keep in mind, and I've also mentioned this in the introduction. Injectors are nice, but using them can make your app do too much work for what you want to achieve. The absolute best practice is to avoid any data-fetching where needed. So try to combine the NestJS guards, with your Authenticator (Such as JWT) and reject calls based on the token payload or session details, as to prevent any data-calls in your application.

DOCUMENTATION
GUIDE
INTEGRATION

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team