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:
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:
- Look up all the
Rolesfrom the database that match the currentuserId. - For global routes, check that at least one of the returned roles is in the list of required roles for the route.
- For scoped routes, do the same, but also check that the
organizationIdof 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!
