Policies with CASL

Attribute-based Rules

When rules depend on data ("a seller may update a product if it belongs to their store, but not after it is archived"), many teams use CASL, a small permissions library that NestJS documents officially.

Example

npm i @casl/ability

Example output

added 2 packages in 2s

src/casl/casl-ability.factory.ts

import { Injectable } from '@nestjs/common';
import { AbilityBuilder, createMongoAbility, ExtractSubjectType, InferSubjects, MongoAbility } from '@casl/ability';
import { Product } from '../products/entities/product.entity';
import { Order } from '../orders/entities/order.entity';

export enum Action { Manage = 'manage', Create = 'create', Read = 'read', Update = 'update', Delete = 'delete' }
type Subjects = InferSubjects<typeof Product | typeof Order> | 'all';
export type AppAbility = MongoAbility<[Action, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: { id: number; roles: string[] }) {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

    if (user.roles.includes('admin')) {
      can(Action.Manage, 'all');
    } else {
      can(Action.Read, Product);
      can(Action.Read, Order, { userId: user.id });
      if (user.roles.includes('seller')) {
        can(Action.Create, Product);
        can(Action.Update, Product, { sellerId: user.id, archived: false });
      }
      cannot(Action.Delete, Order).because('Orders can only be cancelled, not deleted');
    }
    return build({ detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects> });
  }
}

// in a service:
// const ability = this.caslAbilityFactory.createForUser(user);
// if (ability.cannot(Action.Update, product)) throw new ForbiddenException();

The Idea Behind It

Example

A rule is (action, subject, optional conditions). A check finds a rule whose conditions match the object's fields:

type Rule = { action: string; subject: string; conditions?: Record<string, unknown>; inverted?: boolean };
class Product { constructor(public id: number, public sellerId: number, public archived: boolean) {} }

function defineFor(user: { id: number; roles: string[] }): Rule[] {
  if (user.roles.includes('admin')) return [{ action: 'manage', subject: 'all' }];
  const rules: Rule[] = [{ action: 'read', subject: 'Product' }];
  if (user.roles.includes('seller')) rules.push({ action: 'update', subject: 'Product', conditions: { sellerId: user.id, archived: false } });
  return rules;
}

function can(rules: Rule[], action: string, obj: object): boolean {
  const subject = obj.constructor.name;
  const matches = rules.filter((r) =>
    (r.action === action || r.action === 'manage') && (r.subject === subject || r.subject === 'all') &&
    Object.entries(r.conditions ?? {}).every(([k, v]) => (obj as Record<string, unknown>)[k] === v));
  return matches.length > 0 && !matches.some((r) => r.inverted);
}

const seller5 = defineFor({ id: 5, roles: ['seller'] });
const admin = defineFor({ id: 1, roles: ['admin'] });
const own = new Product(7, 5, false);
const other = new Product(8, 9, false);
const archived = new Product(9, 5, true);

console.log('seller 5 update own      :', can(seller5, 'update', own));
console.log('seller 5 update other    :', can(seller5, 'update', other));
console.log('seller 5 update archived :', can(seller5, 'update', archived));
console.log('seller 5 read other      :', can(seller5, 'read', other));
console.log('admin update other       :', can(admin, 'update', other));

Output

seller 5 update own      : true
seller 5 update other    : false
seller 5 update archived : false
seller 5 read other      : true
admin update other       : true