Enhancing NestJS Security with CASL: AWS-Inspired Authorization
Foreword
Access control and permissions management are critical aspects of building secure and robust applications. In the world of Node.js, the casl package has become a popular choice for handling such requirements with elegance and simplicity. However, as applications grow more complex, the need for enhanced policies and better integration with frameworks like NestJS arises. This article introduces casl-policies, an enhancement of the casl package that mimics AWS policies and integrates seamlessly with NestJS. We'll explore the foundation of casl, the enhancements provided by casl-policies, and provide a comprehensive guide on how to implement it in a NestJS application.
Introduction to CASL
The casl package is a powerful and flexible library for managing authorization in JavaScript applications. It allows you to define abilities for different user roles and control access to various resources in your application. The core concept revolves around defining abilities and checking permissions against these abilities.
Here is a simple example of defining abilities using casl:
import { AbilityBuilder, Ability } from '@casl/ability';
const { can, cannot, build } = new AbilityBuilder(Ability);
can('read', 'Article');
cannot('delete', 'Article');
const ability = build();
console.log(ability.can('read', 'Article')); // true
console.log(ability.can('delete', 'Article')); // falseEnhancing CASL with Policies
While casl provides a robust foundation for handling authorization, complex applications often require more structured policies and better integration with frameworks like NestJS. This is where casl-policies comes in. casl-policies builds on top of casl, introducing policy-based access control and utilities to integrate seamlessly with NestJS applications.
Features of casl-policies
- Policy-Based Access Control: Mimics AWS policies to provide a more structured approach to defining permissions.
- Seamless NestJS Integration: Provides guards and utilities tailored for NestJS, making it easier to implement access control in NestJS applications.
- Enhanced Flexibility: Allows for dynamic policy assignment and evaluation based on user roles and resource attributes.
Implementing casl in a NestJS Application
Let’s dive into how you can implement casl-policies in your NestJS application. We will cover the setup, defining policies, and applying them using guards.
Step 1: Setup
First, install the necessary packages:
npm install @casl/ability @casl/ability/extra lodashStep 2: Define Abilities
I’ll provide you with an example of a policy so you can get a better understanding of how it works and how to handle it effectively.
[
{
"version": "2012-10-17",
"statements": [
{
"effect": "Allow",
"actions": ["update"],
"resources": ["Account"],
"fields": ["title"],
"conditions": {
"title": {
"$nin": ["CEO", "CTO"]
}
}
}
]
},
{
"version": "2012-10-17",
"statements": [
{
"effect": "Allow",
"actions": ["update", "delete"],
"resources": ["Account"],
"fields": [],
"conditions": {
"_id": "{{_id}}"
}
}
]
},
{
"version": "2012-10-17",
"statements": [
{
"effect": "Deny",
"actions": ["update", "delete"],
"resources": ["Policy"],
"fields": [],
"conditions": {}
}
]
}
]More formally, the first policy can be interpreted as “a user can update the title of any account as long as the account’s title is neither CEO nor CTO.” The next policy translates to “a user can read or delete their own account,” where {{id}} represents the authenticated user's ID performing the update or delete action. Finally, the last policy states that "a user cannot update or delete any policy."
Get BrianKim’s stories in your inbox
Join Medium for free to get updates from this writer.
Now, let’s define your abilities using casl. Here, we extend the example by defining abilities for a user. Action and Subject will be defined as constants, please change them as your needs.
import { Injectable } from '@nestjs/common';
import {
MongoAbility,
Subject as CaslSubject,
createMongoAbility,
MongoQuery,
RawRuleOf,
} from '@casl/ability';
import { Action, Subject, Effect } from '@constants';
import { get, isEmpty } from 'lodash';
export type Abilities<A extends string, S extends CaslSubject> = [A, S];
export type AppAbility<A extends string, S extends CaslSubject> = MongoAbility<
Abilities<A, S>,
MongoQuery
>;
@Injectable()
export class CaslAbilityFactory<
A extends string = Action,
S extends CaslSubject = Subject,
> {
constructor() {}
async defineAbility(policies: any, user: any): Promise<AppAbility<A, S>> {
// Flatten all statements from policies
const allStatements = policies.flatMap((policy) => policy.statements);
const rules: RawRuleOf<AppAbility<A, S>>[] = [];
// Process all statements
allStatements.forEach((statement) => {
const fields = !isEmpty(statement.fields) ? statement.fields : undefined;
const conditions = !isEmpty(statement.conditions)
? this.replacePlaceholders(statement.conditions, user)
: undefined;
const rule = {
action: statement.actions,
subject: statement.resources,
fields,
conditions,
} as RawRuleOf<AppAbility<A, S>>;
if (statement.effect === Effect.Allow) {
rules.unshift(rule);
} else if (statement.effect === Effect.Deny) {
rule.inverted = true;
// Place deny rules at the end
rules.push(rule);
}
});
return createMongoAbility<AppAbility<A, S>>(rules);
}
/**
* Recursively replaces placeholders in the conditions object with values from the user object.
* @param conditions - The conditions object with placeholders.
* @param user - The user object containing values to replace placeholders.
* @returns The conditions object with placeholders replaced.
*/
private replacePlaceholders(conditions: any, user: any): any {
// Traverse the conditions object
for (const key in conditions) {
if (typeof conditions[key] === 'object' && conditions[key] !== null) {
// Recursively replace placeholders in nested objects
conditions[key] = this.replacePlaceholders(conditions[key], user);
} else if (typeof conditions[key] === 'string') {
// Replace the placeholder if it matches the {{}} pattern
conditions[key] = this.replacePlaceholder(conditions[key], user);
}
}
return conditions;
}
/**
* Replaces a single placeholder with the corresponding value from the user object.
* @param placeholder - The placeholder string.
* @param user - The user object containing values to replace placeholders.
* @returns The string with placeholders replaced.
*/
private replacePlaceholder(placeholder: string, user: any): any {
const regex = /{{(.*?)}}/g;
return placeholder.replace(regex, (_, path) =>
get(user, path.trim(), `{{${path}}}`),
);
}
}You’ll notice that the replacePlaceholders function comes into play. This function is crucial because, in our policies, we often have patterns like {{ field }}. These placeholders are dynamically replaced with the actual values stored in the user property of an incoming request. This approach makes our policy factory more robust and flexible, allowing you to reference the authenticated user's attributes dynamically when building your own policies. This enhancement simplifies the process of creating policies that can adapt to different user contexts and scenarios.
if (statement.effect === Effect.Allow) {
rules.unshift(rule);
} else if (statement.effect === Effect.Deny) {
rule.inverted = true;
// Place deny rules at the end
rules.push(rule);
}This code block ensures that all “allow” statements are set before any “deny” statements. I highly recommend reading the official CASL documentation to gain a better understanding and learn best practices.
Step 3: Create a Policies Guard
Create a guard that uses casl abilities to enforce policies. This guard iterates through each policy fetched from the handler and checks it against the user’s abilities.
import { subject as an, Subject as CaslSubject } from '@casl/ability';
import { permittedFieldsOf } from '@casl/ability/extra';
import { CHECK_POLICIES_KEY, RequiredPolicy } from '@decorators';
import { CaslAbilityFactory } from '@modules/index.services';
import {
BadRequestException,
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { DynamicModelFetcher } from '@utils';
import { omit, isEmpty } from 'lodash';
import { Action, Subject } from '@constants';
@Injectable()
export class PoliciesGuard<
A extends string = Action,
S extends CaslSubject = Subject,
> implements CanActivate
{
constructor(
private readonly reflector: Reflector,
@Inject(DynamicModelFetcher)
private readonly dynamicModelFetcher: DynamicModelFetcher,
@Inject(CaslAbilityFactory)
private caslAbilityFactory: CaslAbilityFactory<A, S>,
) {}
/**
* Main method that determines if the current request can proceed based on defined policies.
* @param context - The execution context, containing the request and response objects.
* @returns A boolean indicating whether the request is allowed.
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
// Retrieve policies defined for the route handler and class
const policies =
this.reflector.getAllAndOverride<RequiredPolicy<A, S>[]>(
CHECK_POLICIES_KEY,
[context.getHandler(), context.getClass()],
) || [];
if (isEmpty(policies)) {
return true;
}
const request = context.switchToHttp().getRequest();
const account = request.user;
// Define the abilities based on the user's policies
const ability = await this.caslAbilityFactory.defineAbility(
account.policies,
account,
);
const conditionContext: Record<string, any> = {};
for (const policy of policies) {
const subject: string = policy.subject as string;
// Validate fields in the request body against updatable fields defined in the policies
if (request.body) {
this.checkUpdatableFields(policy, ability, request);
}
// Fetch entity dynamically if ID is present in the request parameters
if (request.params[`${subject.toLowerCase()}Id`]) {
await this.fetchEntityIfNeeded(subject, request, conditionContext);
}
}
// Check if all policies are satisfied
return this.checkPolicies(policies, ability, conditionContext);
}If a payload is sent with the request, the guard ensures that all data within the payload is permitted by matching the allowed fields defined in the abilities.
/**
* Checks if the fields in the request body are allowed to be updated based on the policy.
* @param policy - The policy that defines the allowed actions and subjects.
* @param ability - The ability object that checks permissions.
* @param request - The incoming request object.
*/
private checkUpdatableFields(
policy: RequiredPolicy<A, S>,
ability: any,
request: any,
): void {
const subjectKey = (policy.subject as string).toLowerCase();
const bodyToCheck = request.body[subjectKey]
? request.body[subjectKey]
: request.body;
// Retrieve the fields that are permitted to be updated
const updatableFields = permittedFieldsOf(
ability,
policy.action,
policy.subject,
{
fieldsFrom: (rule) => rule.fields || [],
},
);
// If there are specific updatable fields defined, check for unallowed fields in the request body
if (updatableFields.length > 0) {
const unallowedFields = Object.keys(omit(bodyToCheck, updatableFields));
if (unallowedFields.length > 0) {
throw new BadRequestException(
`Fields not allowed to be updated: ${unallowedFields.join(', ')}`,
);
}
}
}Furthermore, if the ID of the target resource is present in the request parameters, under the key [resource]Id, it will be fetched by the dynamic fetcher and added to an object for further validation.
/**
* Fetches the entity dynamically based on the policy subject and request parameter ID, if needed.
* @param subject - The subject entity defined in the policy.
* @param request - The incoming request object.
* @param conditionContext - The context object where fetched entities are stored.
*/
private async fetchEntityIfNeeded(
subject: string,
request: any,
conditionContext: Record<string, any>,
): Promise<void> {
const entity = await this.dynamicModelFetcher.fetchEntity(
subject,
request.params[`${subject.toLowerCase()}Id`],
);
if (entity) {
conditionContext[subject.toLowerCase()] = entity;
} else {
throw new BadRequestException(`Entity not found for subject: ${subject}`);
}
}Finally, all policies will be looped through again for final validation. For resources that have an ID in the request parameters, the corresponding instance will be passed as a Subject in the can method. Otherwise, the resource's class name will be used as the Subject.
/**
* Checks if all policies are satisfied based on the abilities and condition context.
* @param policies - The array of policies to be checked.
* @param ability - The ability object that checks permissions.
* @param conditionContext - The context object containing fetched entities.
* @returns A boolean indicating whether all policies are satisfied.
*/
private checkPolicies(
policies: RequiredPolicy<A, S>[],
ability: any,
conditionContext: Record<string, any>,
): boolean {
return policies.every((policy) => {
const subject: string = policy.subject as string;
return ability.can(
policy.action,
conditionContext[subject.toLowerCase()]
? an(subject, conditionContext[subject.toLowerCase()])
: policy.subject,
);
});
}Step 4: Apply Policies
Use decorators to apply policies to your route handlers.
import { SetMetadata } from '@nestjs/common';
import { Subject as CaslSubject } from '@casl/ability';
import { Action, Subject } from '@constants';
export interface RequiredPolicy<
A extends string = Action,
S extends CaslSubject = Subject,
> {
action: A;
subject: S;
}
export const CHECK_POLICIES_KEY = 'check_policy';
export const CheckPolicies = <
A extends string = Action,
S extends CaslSubject = Subject,
>(
...policies: RequiredPolicy<A, S>[]
) => SetMetadata(CHECK_POLICIES_KEY, policies);Apply these policies in your controllers:
import { Controller, Get, Param } from '@nestjs/common';
import { AccountsService } from './accounts.service';
import { CheckPolicies, Serialize } from '@decorators';
@Serialize(AccountDto.Read)
@Controller('accounts')
export class AccountsController {
constructor(private readonly accountService: AccountsService) {}
@CheckPolicies(
{ action: 'read', subject: 'Account' },
/* define more statements here */
)
@Get(':accountId')
async getById(@Param('accountId') id: string) {
return await this.accountService.getById(id);
}
}Step 5: Update the CaslModule
Update the CaslModule to provide and export the necessary services and guards, ensuring Reflector is included.
// casl.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from './ability.factory';
import { PoliciesGuard } from './guards/policies.guard';
import { DynamicModelFetcher } from './utils/dynamic-model-fetcher';
@Module({})
export class CaslModule {
static forRoot(): DynamicModule {
return {
module: CaslModule,
providers: [
Reflector, // Ensure Reflector is provided
DynamicModelFetcher,
CaslAbilityFactory,
PoliciesGuard,
],
exports: [
Reflector, // Ensure Reflector is exported
DynamicModelFetcher,
CaslAbilityFactory,
PoliciesGuard,
],
};
}
}Step 6: Using casl-policies in Your Application
Finally, ensure your AppModule provides the necessary guards and services.
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { CaslAbilityFactory } from './ability.factory';
import { PoliciesGuard } from './guards/policies.guard';
import { DynamicModelFetcher } from './utils/dynamic-model-fetcher';
import { ArticlesController } from './articles.controller';
@Module({
imports: [
/* other imports */
CaslModule.forRoot(),
],
controllers: [AppController],
providers: [
AppService,
/* other providers */,
{
provide: APP_GUARD,
useClass: PoliciesGuard,
},
],
})
export class AppModule {}Conclusion
Access control is a fundamental aspect of application security, and using the right tools can simplify its implementation. The casl package provides a robust foundation for managing permissions, and casl-policies builds on this foundation, offering enhanced features and seamless integration with NestJS. By following the guidelines and examples provided, you can leverage casl-policies to implement comprehensive and flexible access control in your NestJS applications.
With casl-policies, you can define and enforce policies that adapt to your application's needs, ensuring secure and maintainable access control. We encourage you to explore the package further and contribute to its development to help the community build more secure applications.
If you prefer not to build from scratch, you can take advantage of the casl-policies npm package I've developed. This package is ready to use and can be easily integrated into your application. For detailed installation instructions and usage guidelines, refer to the documentation. For additional information and to explore the code, visit the GitHub repository.









