Careeroza
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/abilityExample 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