A common misconception in backend development is treating Authorization as a boolean gate: "Is this user an Admin?" If yes, let them in. If no, block them.
In NestJS, we often slap a generic @Roles('user') decorator on a route and consider the job done. This creates a critical vulnerability: Broken Object Level Authorization (BOLA), also known as IDOR.
If you have an endpoint GET /invoices/:id, and your @Roles('user') guard only checks if the requester is logged in, User A can change the ID in the URL to view User B’s invoice. The Guard passes because User A is indeed a "user." The application fails because it ignores ownership.
To solve this, we must migrate from coarse-grained Role-Based Access Control (RBAC) to fine-grained Attribute-Based Access Control (ABAC). We will implement this using CASL to centralize authorization logic and enforce ownership checks strictly.
The Root Cause: Why RBAC Fails BOLA
RBAC answers the question: "Who is this user conceptually?" ABAC answers the question: "Does this user own this specific piece of data?"
The vulnerability exists in the gap between the HTTP Request and the Database Query.
- Request:
DELETE /articles/99 - RBAC Check: User has
role: 'editor'. Allowed. - Controller: Calls
service.delete(99). - Database: Deletes row 99.
If row 99 belonged to a different editor, the system just committed a violation. The code lacked context. We need a system that inspects the attributes of the subject (the Article) and compares them to the attributes of the user (the ID) before executing the action.
The Fix: Implementing CASL in NestJS
We will build an AbilityFactory that defines rules based on database entities, creating a centralized "Source of Truth" for permissions.
1. Installation
npm install @casl/ability
2. Define Entities and Subjects
First, let's define the shape of our User and the Resource (e.g., Article). We also need an Action enum to avoid magic strings.
// src/casl/casl.enums.ts
export enum Action {
Manage = 'manage', // Wildcard for all actions
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
// src/users/entities/user.entity.ts
export class User {
id: number;
isAdmin: boolean;
// ... other fields
}
// src/articles/entities/article.entity.ts
export class Article {
id: number;
authorId: number;
isPublished: boolean;
title: string;
}
3. The CASL Ability Factory
This is the core component. Instead of writing if (user.id === article.authorId) inside every service method, we define it once here.
// src/casl/casl-ability.factory.ts
import { AbilityBuilder, createMongoAbility, PureAbility, AbilityClass, ExtractSubjectType, InferSubjects, MongoAbility } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { User } from '../users/entities/user.entity';
import { Article } from '../articles/entities/article.entity';
import { Action } from './casl.enums';
// Define the subjects that can be checked
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
// Define the Ability Type
export type AppAbility = MongoAbility<[Action, Subjects]>;
@Injectable()
export class CaslAbilityFactory {
createForUser(user: User) {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
if (user.isAdmin) {
// Admins can do anything to any subject
can(Action.Manage, 'all');
} else {
// Standard Users can read all published articles
can(Action.Read, Article, { isPublished: true });
// CRITICAL BOLA FIX:
// Users can only Update or Delete an Article IF the authorId matches the user.id
can(Action.Update, Article, { authorId: user.id });
can(Action.Delete, Article, { authorId: user.id });
// Users can create articles
can(Action.Create, Article);
}
return build({
// Detect subject type by checking constructor name
detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>,
});
}
}
4. Integration Module
Register the factory in a module to make it injectable.
// src/casl/casl.module.ts
import { Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';
@Module({
providers: [CaslAbilityFactory],
exports: [CaslAbilityFactory],
})
export class CaslModule {}
5. Implementation in the Service Layer
This is where many implementation guides fail. You cannot fully prevent BOLA in a global Guard because the Guard does not usually know which specific row ID is being requested or how to fetch it from the database.
The most robust pattern is to fetch the entity in your Service (or Controller) and immediately check permissions against the loaded instance.
// src/articles/articles.service.ts
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { CaslAbilityFactory } from '../casl/casl-ability.factory';
import { Action } from '../casl/casl.enums';
import { User } from '../users/entities/user.entity';
import { Article } from './entities/article.entity';
@Injectable()
export class ArticlesService {
// Mock DB repository for demonstration
private articles: Article[] = [
{ id: 1, authorId: 100, isPublished: true, title: 'My Public Post' },
{ id: 2, authorId: 200, isPublished: false, title: 'Secret Draft' },
];
constructor(private caslAbilityFactory: CaslAbilityFactory) {}
async update(id: number, user: User, updateData: Partial<Article>): Promise<Article> {
// 1. Fetch the actual resource from the DB
const article = this.articles.find((a) => a.id === id);
if (!article) throw new NotFoundException(`Article #${id} not found`);
// 2. Generate abilities for the requesting user
const ability = this.caslAbilityFactory.createForUser(user);
// 3. Check permissions against the SPECIFIC instance
// This triggers the rule: can(Action.Update, Article, { authorId: user.id })
if (ability.cannot(Action.Update, article)) {
throw new ForbiddenException('You are not allowed to update this article');
}
// 4. Proceed with logic
Object.assign(article, updateData);
return article;
}
async delete(id: number, user: User): Promise<void> {
const article = this.articles.find((a) => a.id === id);
if (!article) throw new NotFoundException();
const ability = this.caslAbilityFactory.createForUser(user);
// Checks ownership automatically based on factory rules
if (ability.cannot(Action.Delete, article)) {
throw new ForbiddenException('You cannot delete this article');
}
this.articles = this.articles.filter(a => a.id !== id);
}
}
6. The Controller
The Controller remains thin. It delegates the user extraction and parameter passing to the service.
// src/articles/articles.controller.ts
import { Controller, Patch, Param, Body, Req, UseGuards, ParseIntPipe } from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; // Assuming you have standard JWT Auth
@Controller('articles')
@UseGuards(JwtAuthGuard)
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateArticleDto: any,
@Req() req: any,
) {
// req.user is populated by JwtAuthGuard
return this.articlesService.update(id, req.user, updateArticleDto);
}
}
Why This Works
The logic flow has shifted fundamentally:
- Declarative Rules: Logic is removed from
if/elsespaghetti code and moved to theCaslAbilityFactory. If you need to change logic (e.g., "Moderators can update but not delete"), you change it in one place, not in 50 controllers. - Instance Checks: By passing the
articleobject instance intoability.cannot(...), CASL reads the properties of that specific object. It seesarticle.authorIdis 200 anduser.idis 100. The rule{ authorId: user.id }evaluates to false. - Fail-Safe: The
cannotcheck defaults to secure. If a rule isn't explicitly defined to allow an action, CASL blocks it.
Conclusion
Standard RBAC decorators like @Roles('admin') are excellent for protecting routes, but they are insufficient for protecting data. BOLA vulnerabilities thrive in environments where access control checks stop at the entry point.
By implementing CASL, you introduce context-aware authorization. You ensure that a valid token and a valid role are not enough—the user must actually own or have specific rights to the object they are trying to manipulate. This closes the BOLA gap completely.