Skip to main content

Preventing BOLA in NestJS: Moving from Simple RBAC to Attribute-Based Access Control (CASL)

 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.

  1. Request: DELETE /articles/99
  2. RBAC Check: User has role: 'editor'. Allowed.
  3. Controller: Calls service.delete(99).
  4. 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:

  1. Declarative Rules: Logic is removed from if/else spaghetti code and moved to the CaslAbilityFactory. If you need to change logic (e.g., "Moderators can update but not delete"), you change it in one place, not in 50 controllers.
  2. Instance Checks: By passing the article object instance into ability.cannot(...), CASL reads the properties of that specific object. It sees article.authorId is 200 and user.id is 100. The rule { authorId: user.id } evaluates to false.
  3. Fail-Safe: The cannot check 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.