Search code examples
nestjsrolesmulti-tenantrbacusergroups

NestJS - How to implement RBAC with organization-scoped roles


I am designing a REST backend in Nest.js that needs to allow Users to be a part of multiple Organizations. I want to use role-based access control, such that a user can have one or more named roles. Crucially, these roles need to be able to be either "global" (not dependent on any organization, ex. SUPERUSER), or "scoped" (specific to an organization, ex. MANAGER).

I have decided on this basic database design, which links Users to Organizations using the Roles table in a many-one-many relationship:

DB schema

As you can see, the organizationId field on a Role is optional, and if it is present, then the user is linked to that organization through the role. If it is not present, I assume this to be a "global" role. I find this to be an elegant database design, but I am having trouble implementing the guard logic for my endpoints.

The guard logic would go something like this:

  1. Look up all the Roles from the database that match the current userId.
  2. For global routes, check that at least one of the returned roles is in the list of required roles for the route.
  3. For scoped routes, do the same, but also check that the organizationId of the role matches the organization ID associated with the operation (I'll elaborate below).

Consider these two endpoints for Jobs. The first will retrieve all the jobs associated with a specified organization. The second will find a single job by its id:

Example route 1:

GET /jobs?organizationId=XXXXX

@Roles(Role.MANAGER, Role.EMPLOYEE)
@UseGuards(JwtAuthGuard, RolesGuard)
@Get()
getMyJobs(@Query() query: {organizationId: string}) {
  return this.jobsService.getJobs({
    organizationId: query.organizationId,
  })
}

Example route 2:

GET /jobs/:jobId

@Roles(Role.MANAGER, Role.EMPLOYEE)
@UseGuards(JwtAuthGuard, RolesGuard)
@Get(':jobId')
getJob(@Param('jobId') jobId: string) {
  return this.jobsService.getJob(jobId)
}

In the first example, I know the organizationId without doing any work because it is required as a query parameter. This id can be matched against the id specified in the Role. This is trivial to validate, and ensures that only users who belong to that organization can access the endpoint.

In the second example, the organizationId is not provided. I can easily query it from the database by looking up the Job, but that is work that should be done in the service/business logic. Additionally, guard logic executes before getJob. This is where I am stuck.

The only solution I can come up with is to pass the organizationId in every request, perhaps as a url parameter or HTTP header. Seems like there should be a better option than that. I'm sure this pattern is very common, but I don't know what it is called to do any research. Any help regarding this implementation would be greatly appreciated!


Solution

  • It is just another option for you.

    You can modify a user object inside RolesGuard by adding a field that stores available organizations for him/her. So you need to calculate organizations for user, who makes a request inside a guard and then put a result array with ids of organizations to a user field (user.availableOrganizationIds = []). And then use it for filtering results

    @Roles(Role.MANAGER, Role.EMPLOYEE)
    @UseGuards(JwtAuthGuard, RolesGuard)
    @Get()
    getMyJobs(@User() user) { // get a user from request
      return this.jobsService.getJobs({
        organizationIds: user.availableOrganizationIds, // <<- filter by organizations
      })
    }