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:
Roles
from the database that match the current userId
.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:
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,
})
}
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!
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
})
}