Authorization with roles and claims
So far, in this series, we’ve implemented authentication. By doing that, we can confirm that the users are who they claim to be. In this series, we explain how…
November 15, 2021
So far, in this series, we’ve implemented authentication. By doing that, we can confirm that the users are who they claim to be.
While authorization might at first glance seem similar to authentication, it serves a different purpose. With authorization, we check the user’s permission to access a specific resource. A good example would be to allow a user to create posts but not delete them. This article presents two different approaches to authorization and presents how to implement them with NestJS.
While authorization is a separate process, it makes sense to have the authentication mechanism implemented first.
Role-based access control (RBAC)#
With role-based access control (RBAC), we assign roles to users. Let’s create an enum containing fundamental roles:
role.enum.ts#
enum Role {
User = "User",
Admin = "Admin",
}
export default RoleWe also need a column to define the role of a particular user. Since in this series we use PostgreSQL, we can use the enum type:
CREATE TYPE user_role AS ENUM ('User', 'Admin');
CREATE TABLE users (
id serial PRIMARY KEY,
email text UNIQUE,
role user_role
)Fortunately, TypeORM supports enums. Let’s add it to the definition of the User entity. We can make it an array to support the user having multiple roles.
user.entity.ts#
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
import Role from "./role.enum"
@Entity()
class User {
@PrimaryGeneratedColumn()
public id: number
@Column({ unique: true })
public email: string
@Column({
type: "enum",
enum: Role,
array: true,
default: [Role.User],
})
public roles: Role[] // ...
}
export default UserAssigning roles to routes#
The official NestJS documentation suggests using two separate decorators: one to assign a role to the route and the second one to check if the user has the role. We can simplify that by creating a guard that accepts a parameter. To do that, we need to create a mixin.
role.guard.ts#
import { CanActivate, ExecutionContext, mixin, Type } from "@nestjs/common"
import RequestWithUser from "../authentication/requestWithUser.interface"
import Role from "./role.enum"
const RoleGuard = (role: Role): Type<CanActivate> => {
class RoleGuardMixin implements CanActivate {
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<RequestWithUser>()
const user = request.user
return user?.roles.includes(role)
}
}
return mixin(RoleGuardMixin)
}
export default RoleGuardAn important thing above is that we use the RequestWithUser interface. For the request to contain the user property, we also need to use the JwtAuthenticationGuard:
posts.controller.ts#
import {
ClassSerializerInterceptor,
Controller,
Delete,
Param,
ParseIntPipe,
UseGuards,
UseInterceptors,
} from "@nestjs/common"
import PostsService from "./posts.service"
import RoleGuard from "../users/role.guard"
import Role from "../users/role.enum"
import JwtAuthenticationGuard from "../authentication/jwt-authentication.guard"
@Controller("posts")
@UseInterceptors(ClassSerializerInterceptor)
export default class PostsController {
constructor(private readonly postsService: PostsService) {}
@Delete(":id")
@UseGuards(RoleGuard(Role.Admin))
@UseGuards(JwtAuthenticationGuard)
async deletePost(@Param("id", ParseIntPipe) id: number) {
return this.postsService.deletePost(id)
} // ...
}If the user does not have the Admin role, NestJS throws 403 Forbidden:
Extending the JwtAuthenticationGuard#
The crucial thing about the above code is the correct order of guards. Since decorators run from bottom to top, we need to use the JwtAuthenticationGuard below the RoleGuard.
To deal with the above issue more elegantly, we can extend our JwtAuthenticationGuard:
role.guard.ts#
import { CanActivate, ExecutionContext, mixin, Type } from "@nestjs/common"
import JwtAuthenticationGuard from "../authentication/jwt-authentication.guard"
import RequestWithUser from "../authentication/requestWithUser.interface"
import Role from "./role.enum"
const RoleGuard = (role: Role): Type<CanActivate> => {
class RoleGuardMixin extends JwtAuthenticationGuard {
async canActivate(context: ExecutionContext) {
await super.canActivate(context)
const request = context.switchToHttp().getRequest<RequestWithUser>()
const user = request.user
return user?.roles.includes(role)
}
}
return mixin(RoleGuardMixin)
}
export default RoleGuardBecause we call await super.canActivate(context), we no longer need to use both JwtAuthenticationGuard and RoleGuard:
posts.controller.ts#
@Delete(':id')
@UseGuards(RoleGuard(Role.Admin))
async deletePost(@Param('id', ParseIntPipe) id: number) {
return this.postsService.deletePost(id);
}Claims-based authorization#
When implementing claims-based authorization, we take a slightly different approach. Instead of defining a few roles, we define multiple permissions.
permission.enum.ts#
enum Permission {
DeletePost = "DeletePost",
CreateCategory = "CreateCategory",
}
export default PermissionUnfortunately, storing all permissions in a single enum might not be a scalable approach. Because of that, we can create multiple enums and merge them.
If you want to read more about merging enums, check this answer on StackOverflow.
categoriesPermission.enum.ts#
enum CategoriesPermission {
CreateCategory = "CreateCategory",
}
export default CategoriesPermissionpostsPermission.enum.ts#
enum PostsPermission {
DeletePost = "DeletePost",
}
export default PostsPermissionpermission.type.ts#
import CategoriesPermission from "../categories/categoriesPermission.enum"
import PostsPermission from "../posts/postsPermission.enum"
const Permission = {
...PostsPermission,
...CategoriesPermission,
}
type Permission = PostsPermission | CategoriesPermission
export default PermissionThanks to the above approach, we can use
Permissionboth as a type and as a value.
Let’s use the above type in the user’s entity definition:
user.entity.ts#
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
import Permission from "./permission.type"
@Entity()
class User {
@PrimaryGeneratedColumn()
public id: number
@Column({ unique: true })
public email: string
@Column({
type: "enum",
enum: Permission,
array: true,
default: [],
})
public permissions: Permission[] // ...
}
export default UserDoing the above with TypeORM creates an enum that consists of all of the permissions we’ve defined.
We can use the Permission type to create the PermissionGuard:
permission.guard.ts#
import { CanActivate, ExecutionContext, mixin, Type } from "@nestjs/common"
import JwtAuthenticationGuard from "../authentication/jwt-authentication.guard"
import RequestWithUser from "../authentication/requestWithUser.interface"
import Permission from "./permission.type"
const PermissionGuard = (permission: Permission): Type<CanActivate> => {
class PermissionGuardMixin extends JwtAuthenticationGuard {
async canActivate(context: ExecutionContext) {
await super.canActivate(context)
const request = context.switchToHttp().getRequest<RequestWithUser>()
const user = request.user
return user?.permissions.includes(permission)
}
}
return mixin(PermissionGuardMixin)
}
export default PermissionGuardAbove, we extend the
JwtAuthenticationGuardto avoid having to use two guards in the same way we’ve done with theRoleGuard.
The last step is to use the PermissionGuard on a route:
@Delete(':id')
@UseGuards(PermissionGuard(PostsPermission.DeletePost))
async deletePost(@Param('id', ParseIntPipe) id: number) {
return this.postsService.deletePost(id);
}Summary#
In this article, we’ve implemented both role-based and claims-based authorization. We’ve done that by defining guards using the mixin pattern. We’ve also learned about the enum type built into PostgreSQL. While learning about authorization, we’ve used two different approaches. While both role-based and claims-based authorization would work, the latter is more customizable. As our application grows, we might find that it is easier to use claims because they are more generic.