npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@klerick/acl-json-api-nestjs

v0.1.0

Published

<p align='center'> <a href="https://www.npmjs.com/package/acl-json-api-nestjs" target="_blank"><img src="https://img.shields.io/npm/v/acl-json-api-nestjs.svg" alt="NPM Version" /></a> <a href="https://www.npmjs.com/package/acl-json-api-nestjs" target=

Readme

acl-json-api-nestjs

Type-safe, flexible Access Control List (ACL) module for NestJS with CASL integration and template-based rule materialization.

⚠️ Module Purpose:

This module was specifically designed to integrate with @klerick/json-api-nestjs, providing:

  • Automatic ACL setup via wrapperJsonApiController hook
  • Transparent ORM-level filtering for JSON:API operations

Can be used standalone with any NestJS application:

  • ⚙️ Manual setup required: apply @AclController decorator and AclGuard to controllers
  • ✅ All features available: template materialization, field-level permissions, context/input interpolation

Features

  • Two-stage materialization - Static rules (context) vs dynamic rules (@input)
  • Guard-based authorization - Fail-fast approach with AclGuard
  • CLS integration - ExtendableAbility available in pipes/guards/services via contextStore
  • Template interpolation - Use ${currentUserId} (context) and ${@input.data} (@input) in rules
  • Lazy evaluation - Rules with @input are materialized only when needed

Installation

npm install @klerick/nestjs-acl-permissions @casl/ability

Recommended: Install nestjs-cls for context store (provides AsyncLocalStorage-based storage):

npm install nestjs-cls

Quick Start

1. Define your RulesLoader

import { Injectable } from '@nestjs/common';
import { AclRulesLoader, AclRule } from '@klerick/nestjs-acl-permissions';

@Injectable()
export class MyRulesLoaderService implements AclRulesLoader {
  async loadRules<E>(entity: any, action: string): Promise<AclRule<E>[]> {
    return [
      {
        action: 'getAll',
        subject: 'Post',
        fields: ['title', 'content'], // Only these fields allowed
      },
      {
        action: 'patchOne',
        subject: 'Post',
        conditions: { authorId: '${currentUserId}' }, // From context
      },
    ];
  }

  async getContext(): Promise<Record<string, unknown>> {
    // Return session data (e.g., current user)
    return {
      currentUserId: 123,
      role: 'user',
    };
  }

  async getHelpers(): Promise<Record<string, (...args: unknown[]) => unknown>> {
    return {}; // Optional helper functions
  }
}

2. Register the module with Context Store

⚠️ IMPORTANT: ACL module requires a contextStore that implements AclContextStore interface and uses AsyncLocalStorage internally.

📦 Recommended: Use nestjs-cls - a ready-made solution:

npm install nestjs-cls
import { Module } from '@nestjs/common';
import { AclPermissionsModule } from '@klerick/nestjs-acl-permissions';
import { ClsModule, ClsService } from 'nestjs-cls';

@Module({
  imports: [
    // ClsModule - recommended context store implementation
    // Uses AsyncLocalStorage for request-scoped data (no REQUEST scope needed!)
    ClsModule.forRoot({
      global: true, // Make ClsService available everywhere
      middleware: {
        mount: true, // Mount middleware to initialize CLS context per-request
      },
    }),

    // ACL module
    AclPermissionsModule.forRoot({
      rulesLoader: MyRulesLoaderService,
      contextStore: ClsService, // Pass any service that implements AclContextStore
      onNoRules: 'deny', // deny | allow (default: 'deny')
      defaultRules: [], // Optional fallback rules
    }),
  ],
})
export class AppModule {}

Why use a Context Store with AsyncLocalStorage?

  • AsyncLocalStorage provides request-scoped data without using Scope.REQUEST
  • Your services remain SINGLETONS (created once) and still access request-specific ACL ability
  • No performance penalty from recreating providers on every request

Custom Implementation (if needed):

You can implement your own contextStore:

interface AclContextStore {
  get<T>(key: symbol | string): T | undefined;
  set<T>(key: symbol | string, value: T): void;
}

// Your custom implementation using AsyncLocalStorage
@Injectable()
export class MyContextStore implements AclContextStore {
  private storage = new AsyncLocalStorage<Map<symbol | string, any>>();

  get<T>(key: symbol | string): T | undefined {
    return this.storage.getStore()?.get(key);
  }

  set<T>(key: symbol | string, value: T): void {
    this.storage.getStore()?.set(key, value);
  }

  // Middleware to initialize storage per-request
  middleware(req, res, next) {
    this.storage.run(new Map(), () => next());
  }
}

3. Apply ACL to controllers

Option A: Automatic (with @klerick/json-api-nestjs)

If you're using @klerick/json-api-nestjs, ACL is applied automatically via hook:

import { Module } from '@nestjs/common';
import { JsonApiModule } from '@klerick/json-api-nestjs';
import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm';
import { wrapperJsonApiController } from '@klerick/nestjs-acl-permissions';

@Module({
  imports: [
    JsonApiModule.forRoot(MicroOrmJsonApiModule, {
      entities: [User, Post, Comment],
      hooks: {
        afterCreateController: wrapperJsonApiController,  // 🔥 Automatic ACL
      },
    }),
  ],
})
export class ResourcesModule {}

The hook automatically applies @AclController and @UseGuards(AclGuard) to all JSON:API controllers that don't have the decorator yet.

Option B: Override per controller (with @klerick/json-api-nestjs)

If the hook is enabled, you can still override ACL settings for specific controllers by applying @AclController manually. The hook will detect the existing decorator and skip it, using your custom settings instead:

import { Controller } from '@nestjs/common';
import { AclController } from '@klerick/nestjs-acl-permissions';
import { JsonBaseController } from '@klerick/json-api-nestjs';

@AclController({
  subject: Post,
  methods: {
    getAll: true,      // Enable ACL with global options
    getOne: true,      // Enable ACL with global options
    patchOne: true,    // Enable ACL with global options
    deleteOne: false,  // Disable ACL for this method
  },
})
export class PostsController extends JsonBaseController<Post> {}

Per-method options override:

You can override onNoRules and defaultRules for specific methods:

@AclController({
  subject: Post,
  methods: {
    getAll: true,  // Uses global onNoRules and defaultRules

    getOne: false, // ACL completely disabled

    patchOne: {    // Override options for this method only
      onNoRules: 'allow',  // Allow if no rules (ignores global 'deny')
      defaultRules: [      // Fallback rules for this method
        {
          action: 'patchOne',
          subject: 'Post',
          conditions: { authorId: '${currentUserId}' },
        },
      ],
    },

    deleteOne: {   // Strict mode for this method
      onNoRules: 'deny',
      defaultRules: [],  // No fallback
    },
  },
})
export class PostsController extends JsonBaseController<Post> {}

Options priority:

Method-specific options > Global module options > Default ('deny')

Option C: Standalone (without @klerick/json-api-nestjs)

You can use this module with regular NestJS controllers. Just apply @AclController decorator and @UseGuards(AclGuard):

import { Controller, Get, Post, Patch, Delete, UseGuards } from '@nestjs/common';
import { AclController, AclGuard } from '@klerick/nestjs-acl-permissions';

@AclController({
  subject: 'Post',  // String subject
  methods: {
    findAll: true,   // Your method names
    findOne: true,
    update: true,
    remove: false,
  },
})
@Controller('posts')
export class PostsController {
  @Get()
  findAll() {
    // Your logic...
  }

  @Get(':id')
  findOne() {
    // Your logic...
  }

  @Patch(':id')
  update() {
    // Your logic...
  }

  @Delete(':id')
  remove() {
    // Your logic...
  }
}

Note: When using standalone mode, you'll need to manually handle ACL checks in your service layer using ExtendAbility.updateWithInput() for @input template materialization.

4. Use ExtendAbility in services (optional)

⚠️ DO NOT use Scope.REQUEST! The ExtendAbility provider is a SINGLETON Proxy that automatically retrieves the ability for the current request from contextStore.

For @klerick/json-api-nestjs: ACL checks are handled automatically at the ORM level. You don't need to inject ExtendAbility in your services unless you have custom logic.

For standalone mode: You need to manually inject and use ExtendAbility:

import { Injectable, Inject, ForbiddenException } from '@nestjs/common';
import { ExtendAbility } from '@klerick/nestjs-acl-permissions';
import { subject } from '@casl/ability';

@Injectable()
export class PostsService {
  // Inject ExtendAbility like any other dependency
  // This is a SINGLETON proxy - your service stays SINGLETON too!
  @Inject(ExtendAbility)
  private readonly ability!: ExtendAbility;

  async updatePost(id: number, data: UpdatePostDto) {
    const post = await this.loadPost(id);

    // Update ability with entity data for @input templates
    this.ability.updateWithInput(post);

    // Check access with materialized rules (context + @input)
    if (!this.ability.can('patchOne', subject('Post', post))) {
      throw new ForbiddenException('Cannot update this post');
    }

    return this.savePost(post, data);
  }

  async deletePost(id: number) {
    const post = await this.loadPost(id);

    // Update ability with entity data
    this.ability.updateWithInput(post);

    // Check deletion access
    if (!this.ability.can('deleteOne', subject('Post', post))) {
      throw new ForbiddenException('Cannot delete this post');
    }

    return this.removePost(post);
  }
}

How it works:

  1. ExtendAbility is a Proxy (not a real instance)
  2. When you call this.ability.can(), the proxy retrieves the actual ability from contextStore
  3. contextStore (via AsyncLocalStorage) automatically returns data for the current request
  4. No Scope.REQUEST needed - your service is still a SINGLETON
  5. updateWithInput() materializes rules with @input data from the entity

Two-stage materialization:

  • Guard level: Rules materialized with context only (fast check)
  • Service level: Call updateWithInput() to materialize rules with @input data (full check)

Template Interpolation System

The ACL module uses a powerful template interpolation system that allows you to embed dynamic values in your rules using ${...} syntax. This section explains how it works in detail.

Template Syntax

Templates use JavaScript-like expressions inside ${}:

// Rule with templates:
{
  action: 'getAll',
  subject: 'Post',
  conditions: {
    authorId: '${currentUserId}',           // Context variable
    status: '${@input.status}',             // Input variable
    createdAt: { $gt: '${yesterday()}' }    // Helper function
  }
}

// After materialization:
{
  action: 'getAll',
  subject: 'Post',
  conditions: {
    authorId: 123,              // Value from context
    status: 'published',        // Value from input
    createdAt: { $gt: '2025-01-10T00:00:00.000Z' }  // Result of helper
  }
}

Important: Templates are strings that contain ${...} expressions. The interpolation happens during rule materialization.

Three Types of Variables

1. Context Variables - ${varName}

Available: Always (materialized at Guard level) Source: AclRulesLoader.getContext() Use case: Session data, current user info, global settings

// In your RulesLoader:
async getContext(): Promise<Record<string, unknown>> {
  return {
    currentUserId: 123,
    currentUser: {
      id: 123,
      role: 'moderator',
      departmentId: 5
    },
    tenantId: 'acme-corp'
  };
}

// In rules:
{
  conditions: {
    authorId: '${currentUserId}',                    // Simple variable
    'author.role': '${currentUser.role}',            // Nested access
    departmentId: '${currentUser.departmentId}',     // Nested property
    tenant: '${tenantId}'                            // Top-level variable
  }
}

// After materialization:
{
  conditions: {
    authorId: 123,
    'author.role': 'moderator',
    departmentId: 5,
    tenant: 'acme-corp'
  }
}

Nested access:

// Context:
{
  currentUser: {
    profile: {
      settings: {
        theme: 'dark'
      }
    }
  }
}

// Rule:
{ conditions: { theme: '${currentUser.profile.settings.theme}' } }
// → { conditions: { theme: 'dark' } }

2. Input Variables - ${@input.field}

Available: Only after updateWithInput() (Service level) Source: Entity data passed to updateWithInput(entity) Use case: Entity-specific conditions, field-level validation

// In service (after fetching entity):
const post = await this.loadPost(id);  // { id: 5, authorId: 123, status: 'draft' }
this.ability.updateWithInput(post);    // Materialize with entity data

// Rules with @input:
{
  conditions: {
    authorId: '${@input.authorId}',      // Field from entity
    status: '${@input.status}',          // Another field
    'tags': { $size: '${@input.tags.length}' }  // Array property
  }
}

// After updateWithInput:
{
  conditions: {
    authorId: 123,           // From post.authorId
    status: 'draft',         // From post.status
    'tags': { $size: 3 }     // From post.tags.length
  }
}

Array operations with .map() syntax:

// Entity:
{
  id: 5,
  tags: [
    { id: 1, name: 'tech' },
    { id: 2, name: 'news' },
    { id: 3, name: 'tutorial' }
  ]
}

// Rule - extract all IDs:
{
  conditions: {
    'tags.id': { $in: '${@input.tags.map(i => i.id)}' }  // Extract all ids
  }
}

// After materialization:
{
  conditions: {
    'tags.id': { $in: [1, 2, 3] }  // Array of extracted values
  }
}

Common patterns:

// Check if array contains value
{ coAuthorIds: { $in: ['${currentUserId}'] } }

// Extract IDs from relationship array
{ 'posts.id': { $in: '${@input.posts.map(i => i.id)}' } }

// Array size validation
{ tags: { $size: '${@input.tags.length}' } }

// All items must match condition
{ comments: { $all: { authorId: '${currentUserId}' } } }

3. __current Variables - ${@input.__current.field}

Available: Only in patchOne and patchRelationship Source: OLD entity values (before update) Use case: Compare old vs new values, validate transitions

// patchOne scenario:
// OLD entity (from DB): { id: 5, status: 'draft', coAuthorIds: [1, 2, 3] }
// NEW data (from request): { status: 'review', coAuthorIds: [2, 3, 4] }

// Entity passed to updateWithInput:
{
  id: 5,
  status: 'review',           // NEW value at root
  coAuthorIds: [2, 3, 4],     // NEW value at root
  __current: {
    id: 5,
    status: 'draft',          // OLD value in __current
    coAuthorIds: [1, 2, 3]    // OLD value in __current
  }
}

// Rules with __current:
{
  conditions: {
    // OLD status must be draft
    '__current.status': 'draft',

    // NEW status must be review or published
    'status': { $in: ['review', 'published'] },

    // NEW array must include all OLD items (can only add, not remove)
    'coAuthorIds': { $all: '${@input.__current.coAuthorIds}' }
  }
}

// After materialization:
{
  conditions: {
    '__current.status': 'draft',
    'status': { $in: ['review', 'published'] },
    'coAuthorIds': { $all: [1, 2, 3] }  // Must contain old IDs
  }
}

Use cases:

  1. State transitions: "Can change status from draft to review, but not to published"
  2. Add-only updates: "Can add items to array but cannot remove existing ones"
  3. Conditional removal: "Can remove only yourself from coAuthors"
  4. Value increase: "Can increase price but not decrease it"

Helper Functions - ${helperName(arg1, arg2)}

Available: Always Source: AclRulesLoader.getHelpers() Use case: Complex calculations, reusable logic

// In your RulesLoader:
async getHelpers(): Promise<Record<string, (...args: unknown[]) => unknown>> {
  return {
    // Helper: Remove userId from array
    removeMyselfOnly: (oldArray: number[], userId: number): number[] => {
      return oldArray.filter(id => id !== userId);
    },

    // Helper: Check if date is in past
    isInPast: (dateStr: string): boolean => {
      return new Date(dateStr) < new Date();
    },

    // Helper: Calculate yesterday
    yesterday: (): string => {
      const date = new Date();
      date.setDate(date.getDate() - 1);
      return date.toISOString();
    },

    // Helper: Extract unique IDs
    uniqueIds: (items: Array<{ id: number }>): number[] => {
      return [...new Set(items.map(i => i.id))];
    }
  };
}

// In rules:
{
  action: 'patchOne',
  subject: 'Article',
  conditions: {
    // CoAuthor can remove only themselves
    'coAuthorIds': {
      $all: '${removeMyselfOnly(@input.__current.coAuthorIds, currentUser.id)}',
      $size: '${@input.__current.coAuthorIds.length - 1}'
    },

    // Must be created in the past
    '__current.createdAt': { $lt: '${yesterday()}' },

    // Check if already published
    'isPublished': '${isInPast(@input.publishedAt)}'
  }
}

Helper function arguments:

You can pass three types of values to helpers:

  1. Context variables: ${helper(currentUserId)}
  2. Input variables: ${helper(@input.tags)}
  3. Literals: ${helper('draft', 5, true)}

Advanced example:

// Helper:
getHelpers() {
  return {
    // Check if user is removing only themselves from array
    isSelfRemovalOnly: (
      oldArray: number[],
      newArray: number[],
      userId: number
    ): boolean => {
      const removed = oldArray.filter(id => !newArray.includes(id));
      return removed.length === 1 && removed[0] === userId;
    }
  };
}

// Rule:
{
  conditions: {
    // Custom validation using helper
    'valid': '${isSelfRemovalOnly(@input.__current.coAuthorIds, @input.coAuthorIds, currentUser.id)}'
  }
}

Two-Stage Materialization

Rules are materialized in two stages for performance:

Stage 1: Guard Level (Context Only)

When: Request enters AclGuard Available variables: Context variables + Helper functions Not available: @input variables

// Original rule:
{
  action: 'patchOne',
  subject: 'Post',
  conditions: {
    departmentId: '${currentUser.departmentId}',  // ✅ Available (context)
    authorId: '${@input.authorId}'                 // ❌ Not available yet
  }
}

// After Stage 1 (Guard):
{
  conditions: {
    departmentId: 5,                    // ✅ Materialized
    authorId: '${@input.authorId}'      // ❌ Still template
  }
}

Guard checks: can('patchOne', 'Post')

  • If rule has only context variables → fully materialized → can evaluate
  • If rule has @input variables → partially materialized → deferred until Stage 2

Stage 2: Service Level (Context + Input)

When: updateWithInput(entity) is called Available variables: All (Context + Input + Helpers)

// After Stage 2 (updateWithInput):
{
  conditions: {
    departmentId: 5,       // ✅ From Stage 1
    authorId: 123          // ✅ Materialized at Stage 2
  }
}

Service checks: can('patchOne', subject('Post', post))

  • All templates materialized → full validation

Flow example:

// 1. Request enters Guard
// → Rules materialized with context (Stage 1)
// → Check: can('patchOne', 'Post') → allowed

// 2. Controller calls service
const post = await this.ormService.getOne(id);  // Fetch entity

// 3. Service updates ability
this.ability.updateWithInput(post);  // Stage 2: materialize with entity data

// 4. Service checks with full data
if (!this.ability.can('patchOne', subject('Post', post))) {
  throw new ForbiddenException();
}

Strict Mode (Error Handling)

Default: strictInterpolation: true (enabled)

When a template references an undefined variable, the behavior depends on strict mode:

Strict Mode Enabled (default)

Throws error immediately:

// Configuration:
AclPermissionsModule.forRoot({
  rulesLoader: MyRulesLoader,
  contextStore: ClsService,
  strictInterpolation: true,  // Default
})

// Rule with typo:
{
  conditions: {
    authorId: '${@input.athourId}'  // Typo: 'athourId' instead of 'authorId'
  }
}

// Error when updateWithInput is called:
// ReferenceError: Property 'input.athourId' is not defined in strict mode
// Available variables: input, currentUserId, currentUser, ...

Benefits:

  • ✅ Catch typos and missing fields early
  • ✅ Fail-fast approach
  • ✅ Clear error messages

Recommended for: Production environments

Strict Mode Disabled

Logs warning, treats undefined as null:

// Configuration:
AclPermissionsModule.forRoot({
  rulesLoader: MyRulesLoader,
  contextStore: ClsService,
  strictInterpolation: false,  // Disable strict mode
})

// Rule with undefined variable:
{
  conditions: {
    authorId: '${@input.athourId}'  // Typo
  }
}

// After materialization:
{
  conditions: {
    authorId: null  // Undefined → null
  }
}

// Warning in logs:
// [WARN] Failed to materialize rules: Cannot read property 'athourId' of undefined.
// Available variables: input, currentUserId, currentUser, ...

Use case: Development, debugging, or when you want lenient behavior

Nested Object Access

Access nested properties using dot notation:

// Context:
{
  currentUser: {
    profile: {
      department: {
        id: 5,
        name: 'Engineering',
        location: {
          city: 'New York',
          country: 'USA'
        }
      }
    },
    permissions: ['read', 'write']
  }
}

// Rules with nested access:
{
  conditions: {
    // Simple nested
    'departmentId': '${currentUser.profile.department.id}',

    // Deep nested
    'location.city': '${currentUser.profile.department.location.city}',

    // Array element
    'permission': '${currentUser.permissions[0]}',  // 'read'

    // Combining nested + array extraction
    'user.permissions': { $in: '${currentUser.permissions}' }
  }
}

// After materialization:
{
  conditions: {
    'departmentId': 5,
    'location.city': 'New York',
    'permission': 'read',
    'user.permissions': { $in: ['read', 'write'] }
  }
}

With @input:

// Entity:
{
  id: 5,
  author: {
    id: 123,
    profile: {
      department: {
        id: 10,
        name: 'Sales'
      }
    }
  },
  tags: [
    { id: 1, category: { name: 'Tech' } },
    { id: 2, category: { name: 'News' } }
  ]
}

// Rules:
{
  conditions: {
    // Nested object
    'authorDepartment': '${@input.author.profile.department.id}',

    // Extract from nested arrays
    'categories': { $in: '${@input.tags.map(i => i.category.name)}' }
  }
}

// After materialization:
{
  conditions: {
    'authorDepartment': 10,
    'categories': { $in: ['Tech', 'News'] }
  }
}

Array Extraction with .map()

Extract properties from all items in an array using .map() syntax:

// Entity:
{
  posts: [
    { id: 1, title: 'Post A', authorId: 123 },
    { id: 2, title: 'Post B', authorId: 123 },
    { id: 3, title: 'Post C', authorId: 456 }
  ]
}

// Extract all IDs:
'${@input.posts.map(i => i.id)}'           // → [1, 2, 3]

// Extract all authorIds:
'${@input.posts.map(i => i.authorId)}'     // → [123, 123, 456]

// Extract all titles:
'${@input.posts.map(i => i.title)}'        // → ['Post A', 'Post B', 'Post C']

// Use in conditions:
{
  conditions: {
    // Check if specific post ID exists
    'posts.id': { $in: '${@input.posts.map(i => i.id)}' },

    // All posts must be by current user
    'posts': {
      $all: { authorId: '${currentUserId}' }
    }
  }
}

Nested extraction:

// Entity with nested arrays:
{
  posts: [
    {
      id: 1,
      tags: [
        { id: 10, name: 'tech' },
        { id: 20, name: 'news' }
      ]
    },
    {
      id: 2,
      tags: [
        { id: 30, name: 'tutorial' }
      ]
    }
  ]
}

// Extract all tag IDs from all posts:
// ❌ This doesn't work: '${@input.posts.map(p => p.tags.map(t => t.id))}'  // Returns nested arrays
// ✅ Use helper function with flatMap instead:

// Helper:
getHelpers() {
  return {
    flattenTagIds: (posts: Array<{ tags: Array<{ id: number }> }>): number[] => {
      return posts.flatMap(p => p.tags.map(t => t.id));
    }
  };
}

// Rule:
{ conditions: { 'tagIds': { $in: '${flattenTagIds(@input.posts)}' } } }
// → { 'tagIds': { $in: [10, 20, 30] } }

Type Handling

The interpolation system handles different types correctly:

// String:
'${@input.name}'              // → "John Doe"

// Number:
'${@input.age}'               // → 25

// Boolean:
'${@input.isActive}'          // → true

// null:
'${@input.deletedAt}'         // → null

// undefined (strict mode off):
'${@input.missing}'           // → null

// Array:
'${@input.tags}'              // → [1, 2, 3]

// Object:
'${@input.metadata}'          // → { "key": "value" }

// Date:
'${@input.createdAt}'         // → "2025-01-11T00:00:00.000Z" (ISO string)

// Nested:
'${@input.user.profile.bio}'  // → "Software engineer"

// Array of objects:
'${@input.posts.map(i => i.id)}'       // → [1, 2, 3]

Edge Cases and Limitations

1. Escaping ${ in string values

If your data contains literal ${, it won't be treated as a template:

// Context with literal ${}:
{
  message: 'Use ${variable} syntax'  // This is data, not a template
}

// Rule:
{ conditions: { msg: '${message}' } }

// After materialization:
{ conditions: { msg: 'Use ${variable} syntax' } }  // ✅ Works fine

Templates are only evaluated in rule definitions, not in data values.

2. Circular references

Circular references in context/input will cause errors:

// ❌ Bad:
const user = { id: 123 };
user.self = user;  // Circular reference

this.ability.updateWithInput(user);  // Error: Converting circular structure to JSON

Solution: Don't pass circular structures to updateWithInput()

3. Nested .map() returns nested arrays

// ✅ Works - single level:
'${@input.posts.map(i => i.id)}'                    // Extract IDs from posts → [1, 2, 3]

// ❌ Doesn't work - nested arrays:
'${@input.posts.map(p => p.tags.map(t => t.id))}'   // Returns [[1,2], [3,4]] instead of [1,2,3,4]

// ✅ Use helper function with flatMap:
getHelpers() {
  return {
    extractNestedIds: (posts) => posts.flatMap(p => p.tags.map(t => t.id))
  };
}
{ conditions: { ids: '${extractNestedIds(@input.posts)}' } }

4. Undefined vs null

  • undefined properties are converted to null in JSON (JSON spec)
  • In strict mode, accessing undefined property throws error before conversion
// Entity:
{ id: 5, name: 'John' }  // No 'age' property

// Rule:
{ conditions: { age: '${@input.age}' } }

// Strict mode ON: ReferenceError (property not defined)
// Strict mode OFF: { age: null }

5. Helper functions must be synchronous

// ❌ Bad: Async helper
getHelpers() {
  return {
    fetchUser: async (id) => {  // ❌ Async not supported
      return await db.getUser(id);
    }
  };
}

// ✅ Good: Sync helper
getHelpers() {
  return {
    calculateAge: (birthDate: string): number => {
      return new Date().getFullYear() - new Date(birthDate).getFullYear();
    }
  };
}

Why? Rule materialization happens synchronously for performance.

6. Template expressions must be valid JavaScript

// ✅ Valid:
'${@input.age > 18}'                          // Boolean expression
'${@input.tags.length}'                       // Property access
'${helper(@input.value, "test", 123)}'        // Function call

// ❌ Invalid:
'${@input.age > 18 ? "adult" : "minor"}'      // Ternary not supported (use helper)
'${const x = 5; return x * 2;}'               // Statements not supported

Common Patterns

Pattern 1: Owner-only access

{
  action: 'patchOne',
  subject: 'Post',
  conditions: {
    authorId: '${@input.authorId}',        // Entity must belong to user
    'author.id': '${currentUserId}'        // Alternative: nested check
  }
}

Pattern 2: Role-based with field restrictions

// Context:
{ currentUser: { role: 'moderator' } }

// Rules:
[
  {
    action: 'getAll',
    subject: 'User',
    conditions: { role: 'user' },  // Can see only regular users
  },
  {
    action: 'getAll',
    subject: 'User',
    conditions: { id: '${currentUser.id}' },  // Can see own profile
    fields: ['*']  // All fields for own profile
  }
]

Pattern 3: State machine transitions

{
  action: 'patchOne',
  subject: 'Order',
  conditions: {
    '__current.status': 'pending',         // OLD status
    'status': { $in: ['processing', 'cancelled'] }  // NEW status (allowed transitions)
  }
}

Pattern 4: Array manipulation with helpers

// Helper:
getHelpers() {
  return {
    canRemoveOnly: (oldArray: number[], newArray: number[], userId: number): boolean => {
      const removed = oldArray.filter(id => !newArray.includes(id));
      const added = newArray.filter(id => !oldArray.includes(id));
      return added.length === 0 && removed.length === 1 && removed[0] === userId;
    }
  };
}

// Rule: CoAuthor can only remove themselves
{
  conditions: {
    '__current.coAuthorIds': { $in: ['${currentUserId}'] },  // Was coauthor
    'valid': '${canRemoveOnly(@input.__current.coAuthorIds, @input.coAuthorIds, currentUserId)}'
  }
}

API Reference

ExtendAbility

The ExtendAbility class extends CASL's PureAbility and provides additional features for template materialization and query extraction.

Injection:

import { Injectable, Inject } from '@nestjs/common';
import { ExtendAbility } from '@klerick/nestjs-acl-permissions';

@Injectable()
export class MyService {
  @Inject(ExtendAbility)
  private readonly ability!: ExtendAbility;
}

Methods

updateWithInput(input: AclInputData): void

Re-materializes ALL rules with @input data. This is the second stage of materialization.

// First stage (in Guard): rules materialized with context only
// ability.can('patchOne', 'Post') // Uses ${currentUserId}

// Second stage (in Service): re-materialize with @input
this.ability.updateWithInput(entity);
// Now rules with ${@input.userId} are also materialized

Parameters:

  • input: AclInputData - Any object with data for ${@input.*} templates

Example:

const post = await this.getPost(id);
this.ability.updateWithInput(post); // Materialize with post data

// Now you can use rules like:
// { conditions: { authorId: '${@input.authorId}' } }

can(action: string, subject: any, field?: string): boolean

Check if action is allowed on subject. This is the native CASL method.

Parameters:

  • action: string - Action name (e.g., 'getAll', 'patchOne')
  • subject: any - Subject to check (entity class, instance, or string)
  • field?: string - Optional field name for field-level checks

Returns: boolean - true if allowed, false otherwise

Examples:

import { subject } from '@casl/ability';

// Action-level check
if (this.ability.can('getAll', 'Post')) {
  // Allowed to get all posts
}

// Entity-level check (with instance)
const post = await this.getPost(id);
if (this.ability.can('patchOne', subject('Post', post))) {
  // Allowed to patch THIS specific post
}

// Field-level check
if (this.ability.can('getAll', 'Post', 'title')) {
  // Allowed to read 'title' field
}

⚠️ Important:

  • For entity instances, use subject('EntityName', instance) helper from CASL
  • Field-level checks require fields in rules
  • Always call updateWithInput() before checking if you need @input data

hasConditions: boolean

Getter that returns true if any rule contains conditions.

Use case: Optimization - skip query modifications if no conditions exist.

if (this.ability.hasConditions) {
  // Fetch data with ACL query filtering
  const aclQuery = this.ability.getQueryObject();
  // ...
} else {
  // Fast path - fetch without ACL filtering
}

hasFields: boolean

Getter that returns true if any rule contains fields.

Use case: Optimization - skip field filtering if no field restrictions exist.

if (this.ability.hasFields) {
  // Need to filter fields
} else {
  // Fast path - no field filtering needed
}

hasConditionsAndFields(): boolean

Returns true if any rule has BOTH conditions AND fields.

Use case: Determine filtering strategy.

if (this.ability.hasConditionsAndFields()) {
  // Need both query filtering AND field filtering
}

getQueryObject<E, IdKey>(): { fields?, include?, rulesForQuery? }

Extracts query data from ACL conditions. Used internally by ORM Proxy.

Returns:

{
  fields?: {
    target?: string[];        // Entity fields to fetch
    [relation: string]?: string[];  // Relationship fields to fetch
  };
  include?: string[];         // Relations to include (JOIN)
  rulesForQuery?: Record<string, unknown>;  // Knex-compatible query object
}

About rulesForQuery:

  • Returns a Knex-compatible query object (not raw MongoDB)
  • Can be used directly with MikroORM's query builder
  • For @klerick/json-api-nestjs: Handled automatically by ORM Proxy, you don't need to use it
  • For standalone: Can be used to build filtered queries manually

Example:

const aclData = this.ability.getQueryObject();

// Rules: [{ conditions: { authorId: 123, 'profile.isPublic': true } }]
// Returns:
// {
//   fields: { target: ['authorId'], profile: ['isPublic'] },
//   include: ['profile'],
//   rulesForQuery: { authorId: 123, profile: { isPublic: true } }
// }

// Usage with MikroORM (standalone mode):
const qb = em.createQueryBuilder(Post);
if (aclData.rulesForQuery) {
  qb.where(aclData.rulesForQuery);
}

Use case: Used by ORM Proxy to automatically filter queries with ACL conditions. If you're using @klerick/json-api-nestjs, this is handled transparently - you typically don't need to call this manually.


get action(): string

Returns the current action name.

console.log(this.ability.action); // 'getAll'

get subject(): string

Returns the current subject name.

console.log(this.ability.subject); // 'Post'

get rules(): RawRuleFrom[]

Returns the original rules array (before materialization).

Use case: Debugging, logging, or custom logic.

console.log(this.ability.rules);
// [
//   { action: 'getAll', subject: 'Post', conditions: { authorId: '${currentUserId}' } }
// ]

get context(): Record<string, unknown>

Returns the context object used for materialization.

console.log(this.ability.context);
// { currentUserId: 123, role: 'admin' }

get helpers(): Record<string, Function>

Returns the helper functions object.

console.log(this.ability.helpers);
// { extractIds: [Function], isSameDepartment: [Function] }

CASL Methods

Since ExtendAbility extends PureAbility, you also have access to all CASL methods:

  • cannot(action, subject, field?) - Inverse of can()
  • relevantRuleFor(action, subject, field?) - Get relevant rule
  • rulesFor(action, subject) - Get all rules for action/subject

See CASL documentation for full API.


Integration with @klerick/json-api-nestjs

Automatic Protection via Hook

The ACL module integrates seamlessly with @klerick/json-api-nestjs via the hook system:

import { Module } from '@nestjs/common';
import { JsonApiModule } from '@klerick/json-api-nestjs';
import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm';
import { AclPermissionsModule, wrapperJsonApiController } from '@klerick/nestjs-acl-permissions';
import { ClsModule, ClsService } from 'nestjs-cls';

@Module({
  imports: [
    // CLS for storing ExtendAbility
    ClsModule.forRoot({ global: true, middleware: { mount: true } }),

    // ACL module
    AclPermissionsModule.forRoot({
      rulesLoader: MyRulesLoaderService,
      contextStore: ClsService,
      onNoRules: 'deny',  // Default behavior
    }),

    // JSON API with ACL hook
    JsonApiModule.forRoot(MicroOrmJsonApiModule, {
      entities: [User, Post, Comment],
      hooks: {
        afterCreateController: wrapperJsonApiController,  // 🔥 ACL integration
      },
    }),
  ],
})
export class ResourcesModule {}

What happens:

  1. JSON API creates controllers for each entity (UserJsonApiController, PostJsonApiController, etc.)
  2. wrapperJsonApiController hook automatically:
    • Applies @AclController metadata with entity as subject
    • Applies @UseGuards(AclGuard) to protect all methods
    • Wraps ORM service methods with ACL filtering proxies
  3. All JSON:API endpoints are now ACL-protected automatically with transparent ORM-level filtering

ORM-Level Filtering

Key Feature: ACL filtering happens at the ORM level, not in pipes or interceptors.

// When user calls: GET /posts
//
// 1. AclGuard checks: can('getAll', 'Post')
// 2. If allowed, ExtendAbility is stored in CLS
// 3. Controller calls ormService.getAll(query)
// 4. ORM Proxy intercepts the call:
//    - Extracts ACL conditions via ability.getQueryObject()
//    - Merges user query with ACL query (fields, includes, conditions)
//    - Fetches data with ACL filtering applied
//    - Filters fields per-item if needed (field-level permissions)
//    - Returns filtered result

Benefits:

  • Transparent - Controllers don't need to know about ACL
  • Performant - Database-level filtering (WHERE clauses)
  • Secure - Field-level filtering after fetch if needed
  • Complete - Handles all JSON:API operations (CRUD + relationships)

Important: onNoRules Behavior

⚠️ Default Behavior: If onNoRules: 'deny' (default) and no rules are found, ACL will block access with 403 Forbidden.

// Configuration:
AclPermissionsModule.forRoot({
  rulesLoader: MyRulesLoader,
  contextStore: ClsService,
  onNoRules: 'deny',       // Default: deny if no rules
  defaultRules: [],        // Default: no fallback rules
})

// If MyRulesLoader returns empty array:
async loadRules(subject, action) {
  return [];  // No rules!
}

// Result: 403 Forbidden
// {
//   "errors": [{
//     "code": "forbidden",
//     "message": "not allow access",
//     "path": []
//   }]
// }

Override per controller/method:

@AclController({
  subject: Post,
  methods: {
    getAll: {
      onNoRules: 'allow',  // Override: allow if no rules for this method
    },
    patchOne: true,  // Use global onNoRules: 'deny'
  },
})
export class PostsController extends JsonBaseController<Post> {}

Use cases:

  • Strict mode (onNoRules: 'deny'): Require explicit rules for every action
  • Development mode (onNoRules: 'allow'): Allow access while rules are being developed
  • Per-method override: Strict for mutations, relaxed for reads

What happens with onNoRules: 'allow':

AclPermissionsModule.forRoot({
  rulesLoader: MyRulesLoader,
  contextStore: ClsService,
  onNoRules: 'allow',  // Allow access if no rules + log warning
})

// If MyRulesLoader returns empty array:
async loadRules(subject, action) {
  return [];  // No rules!
}

// Result: Access ALLOWED + Warning in logs
// ⚠️ Warning: No ACL rules found for action 'getAll' on subject 'Post'. Access allowed by onNoRules: 'allow'

JSON:API Actions Reference

The module uses JSON:API method names as actions. Here's the complete mapping:

| HTTP Method | Path | Action | Description | |-------------|------|--------|-------------| | GET | /posts | getAll | List all posts | | GET | /posts/:id | getOne | Get single post | | POST | /posts | postOne | Create new post | | PATCH | /posts/:id | patchOne | Update post | | DELETE | /posts/:id | deleteOne | Delete post | | GET | /posts/:id/relationships/:relName | getRelationship | Get relationship data | | POST | /posts/:id/relationships/:relName | postRelationship | Add to relationship | | PATCH | /posts/:id/relationships/:relName | patchRelationship | Replace relationship | | DELETE | /posts/:id/relationships/:relName | deleteRelationship | Remove from relationship |

Example rules for all actions:

@Injectable()
export class MyRulesLoaderService implements AclRulesLoader {
  async loadRules<E>(entity: any, action: string): Promise<AclRule<E>[]> {
    if (entity === Post) {
      return [
        // Read access for all posts
        {
          action: 'getAll',
          subject: 'Post',
          fields: ['id', 'title', 'content', 'createdAt'], // Field-level restrictions
        },
        // Read single post
        {
          action: 'getOne',
          subject: 'Post',
          fields: ['id', 'title', 'content', 'createdAt', 'authorId'],
        },
        // Create new post
        {
          action: 'postOne',
          subject: 'Post',
        },
        // Update: only author can update
        {
          action: 'patchOne',
          subject: 'Post',
          conditions: { authorId: '${currentUserId}' }, // Entity-level condition
          fields: ['title', 'content'], // Can only update these fields
        },
        // Delete: only author can delete
        {
          action: 'deleteOne',
          subject: 'Post',
          conditions: { authorId: '${currentUserId}' },
        },
        // Relationship access
        {
          action: 'getRelationship',
          subject: 'Post',
          fields: ['author', 'comments'], // Can only access these relationships
        },
        {
          action: 'postRelationship',
          subject: 'Post',
          conditions: { authorId: '${currentUserId}' },
          fields: ['comments'], // Can only add comments
        },
        {
          action: 'patchRelationship',
          subject: 'Post',
          conditions: { authorId: '${currentUserId}' },
          fields: ['tags'], // Can only replace tags
        },
        {
          action: 'deleteRelationship',
          subject: 'Post',
          conditions: { authorId: '${currentUserId}' },
          fields: ['tags'], // Can only remove tags
        },
      ];
    }

    return []; // No rules for other entities
  }

  async getContext() {
    return {
      currentUserId: this.request.user?.id,
      role: this.request.user?.role,
    };
  }
}

How ACL Works for Each Method

getAll - List All Entities

Flow:

GET /posts
↓
1. AclGuard checks: can('getAll', 'Post')
2. ORM Proxy intercepts ormService.getAll(query)
3. Prepare ACL query:
   - Extract conditions from ability.getQueryObject()
   - Extract field restrictions from ability.getQueryObject()
   - Merge user query with ACL query
4. Validate: no __current templates (not supported for getAll)
5. Execute query with ACL filtering (WHERE clauses)
6. Post-process results:
   - For each item: check field-level permissions
   - Build fieldRestrictions array for items with hidden fields
   - Transform to JSON:API format
7. Return: { meta: { fieldRestrictions }, data, included }

Three ACL Scenarios:

1. No conditions, all fields (admin)

// Rule:
{
  action: 'getAll',
  subject: 'UserProfile',
  // No conditions = all records
  // No fields = all fields visible
}

// Result: All profiles with all fields
// GET /user-profiles
// => [
//      { id: 1, firstName: 'John', salary: 5000, role: 'admin', ... },
//      { id: 2, firstName: 'Jane', salary: 6000, role: 'moderator', ... }
//    ]

2. No conditions, limited fields (moderator)

// Rule:
{
  action: 'getAll',
  subject: 'UserProfile',
  fields: ['id', 'firstName', 'lastName', 'avatar', 'phone'], // Only these fields
}

// Result: All profiles but some fields hidden
// GET /user-profiles
// => [
//      { id: 1, firstName: 'John', lastName: 'Doe', avatar: '...', phone: '...' },
//      // salary and role are REMOVED from response
//    ]
// meta: {
//   fieldRestrictions: [
//     { id: 1, fields: ['salary', 'role'] },
//     { id: 2, fields: ['salary', 'role'] }
//   ]
// }

3. With conditions, per-item field restrictions (user)

// Rules:
[
  {
    action: 'getAll',
    subject: 'UserProfile',
    conditions: { isPublic: true }, // Only public profiles
    fields: ['id', 'firstName', 'lastName', 'avatar', 'bio'],
  },
  {
    action: 'getAll',
    subject: 'UserProfile',
    conditions: { userId: '${currentUserId}' }, // Own profile
    fields: ['id', 'firstName', 'lastName', 'avatar', 'bio', 'phone'], // + phone
  }
]

// Result: Filtered records + different fields per item
// GET /user-profiles
// => Database query: WHERE isPublic = true OR userId = 123
// => [
//      { id: 1, firstName: 'John', ... },           // public profile
//      { id: 2, firstName: 'Jane', phone: '...', ... }, // own profile (has phone)
//      { id: 3, firstName: 'Bob', ... }             // public profile
//    ]
// => Items 1,3: phone field REMOVED (not in first rule)
// => Item 2: phone field VISIBLE (matches second rule)

Key Points:

  • Database-level filtering: conditions become WHERE clauses
  • Per-item field restrictions: Each item can have different visible fields
  • Meta information: fieldRestrictions tells which fields were hidden
  • Empty results: If no records match ACL conditions, returns empty array per JSON:API spec
  • ⚠️ No __current support: Can use only ${@input.*} without __current. ${@input} is each row from a query result
  • ⚠️ Multiple rules merge: If multiple rules match, fields are combined (union)

Empty Result Example:

// Rules: Only public profiles OR own profile
[
  { action: 'getAll', subject: 'UserProfile', conditions: { isPublic: true } },
  { action: 'getAll', subject: 'UserProfile', conditions: { userId: 123 } }
]

// Database: No public profiles AND user 123 has no profile
// Result: Empty array (per JSON:API spec)
GET /user-profiles
=> {
     meta: { totalItems: 0, pageNumber: 1, pageSize: 25 },
     data: []
   }

⚠️ IMPORTANT: Query Construction Safety

The ability.getQueryObject() converts ACL conditions to database queries. Be careful when writing rules - complex conditions might fail to convert:

// ❌ BAD: Complex nested conditions that might fail conversion
{
  conditions: {
    $or: [
      { 'profile.department.name': { $in: ['Sales', 'Marketing'] } },
      { 'permissions.admin': { $gt: 5 } }
    ]
  }
}

// ✅ GOOD: Simple, flat conditions
{
  conditions: {
    isPublic: true,
    authorId: '${currentUserId}'
  }
}

Error Handling:

If ACL rules produce an invalid database query:

  • Production mode (NODE_ENV=production):

    • Returns 403 Forbidden (masks DB error as ACL denial)
    • Logs error: [ACL] Query error in getAllProxy for subject 'Post': <error details>
  • Development mode:

    • Returns 500 Internal Server Error (exposes DB error for debugging)
    • Logs error with full stack trace

Example:

// Rule with typo in field name:
{
  action: 'getAll',
  subject: 'Post',
  conditions: { auhtorId: 123 }  // typo: auhtorId instead of authorId
}

// Database error: column "auhtorId" does not exist
// → Production: 403 Forbidden
// → Development: 500 + "column 'auhtorId' does not exist"

Recommendations:

  1. Test ACL rules thoroughly in development
  2. Use simple, flat conditions whenever possible
  3. Monitor logs for ACL query errors in production
  4. Validate field names match your entity schema

getOne - Get Single Entity

Flow:

GET /posts/:id
↓
1. AclGuard checks: can('getOne', 'Post')
2. ORM Proxy intercepts ormService.getOne(id, query)
3. Prepare ACL query:
   - Extract conditions from ability.getQueryObject()
   - Extract field restrictions from ability.getQueryObject()
   - Merge user query with ACL query
4. Validate: no __current templates (not supported for getOne)
5. Execute query with ACL filtering (WHERE id = :id AND <ACL conditions>)
6. If not found → 404 Not Found
7. Post-process result:
   - Check field-level permissions for the item
   - Build fieldRestrictions if fields were hidden
   - Transform to JSON:API format
8. Return: { meta: { fieldRestrictions }, data, included }

Three ACL Scenarios:

1. No conditions, all fields (admin)

// Rule:
{
  action: 'getOne',
  subject: 'UserProfile',
  // No conditions = can access any profile by ID
  // No fields = all fields visible
}

// Result: Any profile with all fields
// GET /user-profiles/1
// => { id: 1, firstName: 'John', salary: 5000, role: 'admin', ... }

2. No conditions, limited fields (moderator)

// Rule:
{
  action: 'getOne',
  subject: 'UserProfile',
  fields: ['id', 'firstName', 'lastName', 'avatar', 'phone'],
}

// Result: Any profile but some fields hidden
// GET /user-profiles/1
// => { id: 1, firstName: 'John', lastName: 'Doe', avatar: '...', phone: '...' }
// salary and role are REMOVED
//
// meta: {
//   fieldRestrictions: [{ id: 1, fields: ['salary', 'role'] }]
// }

3. With conditions, per-item field restrictions (user)

// Rules:
[
  {
    action: 'getOne',
    subject: 'UserProfile',
    conditions: { isPublic: true }, // Only public profiles
    fields: ['id', 'firstName', 'lastName', 'avatar', 'bio'],
  },
  {
    action: 'getOne',
    subject: 'UserProfile',
    conditions: { userId: '${currentUserId}' }, // Own profile
    fields: ['id', 'firstName', 'lastName', 'avatar', 'bio', 'phone'], // + phone
  }
]

// Scenario A: Own profile
// GET /user-profiles/123 (currentUserId = 123)
// => Database query: WHERE id = 123 AND (isPublic = true OR userId = 123)
// => { id: 123, firstName: 'John', phone: '...', ... }  // ✅ Has phone (own profile)

// Scenario B: Public profile
// GET /user-profiles/456 (other user's public profile)
// => Database query: WHERE id = 456 AND (isPublic = true OR userId = 123)
// => { id: 456, firstName: 'Jane', ... }  // ✅ No phone (public profile)

// Scenario C: Private profile of another user
// GET /user-profiles/789 (other user's private profile)
// => Database query: WHERE id = 789 AND (isPublic = true OR userId = 123)
// => No match (not public AND not own) → 404 Not Found

Key Points:

  • Database-level filtering: conditions + ID filter combined with AND
  • Field restrictions: Single item can have hidden fields
  • Meta information: fieldRestrictions tells which fields were hidden
  • ⚠️ 404 if not found: If entity doesn't exist OR doesn't match ACL conditions → 404
  • ⚠️ No __current support: Can use only ${@input.*} without __current. ${@input} is row from a query result
  • ⚠️ Multiple rules merge: If multiple rules match, fields are combined (union)

404 Not Found vs 403 Forbidden:

// Scenario 1: Entity doesn't exist
GET /posts/99999 (doesn't exist)
→ 404 Not Found (standard behavior)

// Scenario 2: Entity exists but ACL denies access
GET /posts/5 (exists but not public, and not yours)
→ 404 Not Found (ACL filtered it out)

// Why 404 instead of 403?
// - Security: Don't leak information about resource existence
// - ACL filtering at DB level returns null → appears as "not found"

Important: getOne uses the same error handling as getAll:

  • Invalid ACL rules → Production: 403, Development: 500
  • Same recommendations apply (test rules, use simple conditions, monitor logs)

deleteOne - Delete Single Entity

Flow:

DELETE /posts/:id
↓
1. AclGuard checks: can('deleteOne', 'Post')
2. ORM Proxy intercepts ormService.deleteOne(id)
3. Fetch entity without ACL filtering (just by ID)
4. If not found → throw error (404)
5. Two-stage check with @input support:
   - updateWithInput(entity) - materialize rules with entity data
   - Check: can('deleteOne', subject('Post', entity))
6. If denied → 403 Forbidden
7. If allowed → execute delete
8. Return: void (successful deletion)

Three ACL Scenarios:

1. No conditions (admin)

// Rule:
{
  action: 'deleteOne',
  subject: 'Article',
  // No conditions = can delete any article
}

// Result: Any article can be deleted
// DELETE /articles/1 → ✅ Success (200)
// DELETE /articles/2 → ✅ Success (200)

2. Simple conditions with @input (moderator)

// Rule:
{
  action: 'deleteOne',
  subject: 'Article',
  conditions: { status: 'published' }, // Only published articles
}

// Scenario A: Article is published
// DELETE /articles/1 (article.status = 'published')
// → Fetch article → updateWithInput(article)
// → Check: can('deleteOne', article) → conditions match
// → ✅ Success (200)

// Scenario B: Article is draft
// DELETE /articles/2 (article.status = 'draft')
// → Fetch article → updateWithInput(article)
// → Check: can('deleteOne', article) → conditions don't match
// → ❌ 403 Forbidden

3. Complex conditions with @input (user)

// Rule: Only author can delete unpublished articles
{
  action: 'deleteOne',
  subject: 'Article',
  conditions: {
    authorId: '${@input.authorId}',  // Must be author
    status: { $ne: 'published' }     // Cannot be published
  }
}

// Scenario A: Own draft article
// DELETE /articles/5 (authorId = 123, status = 'draft', currentUserId = 123)
// → Fetch article → updateWithInput(article)
// → Materialize: authorId: 123 (from @input), status != 'published'
// → Check: can('deleteOne', article) → ✅ Both conditions match
// → ✅ Success (200)

// Scenario B: Own published article
// DELETE /articles/6 (authorId = 123, status = 'published', currentUserId = 123)
// → Fetch article → updateWithInput(article)
// → Check: can('deleteOne', article) → ❌ status = 'published' (not allowed)
// → ❌ 403 Forbidden
// {
//   "errors": [{
//     "code": "forbidden",
//     "message": "not allow \"deleteOne\"",
//     "path": ["action"]
//   }]
// }

// Scenario C: Someone else's draft article
// DELETE /articles/7 (authorId = 456, status = 'draft', currentUserId = 123)
// → Fetch article → updateWithInput(article)
// → Check: can('deleteOne', article) → ❌ authorId doesn't match
// → ❌ 403 Forbidden

Key Points:

  • Two-stage check: Fetch entity first, then check with @input data
  • @input support: Can use ${@input.field} in conditions (access to entity data)
  • Instance-level check: Rules evaluated against actual entity instance
  • ⚠️ 403 on denial: Returns 403 Forbidden (not 404) because entity exists and was loaded
  • ⚠️ No __current support: Cannot compare old/new values (no update context)
  • ⚠️ No field restrictions: fields parameter ignored for delete operations

403 Forbidden vs 404 Not Found:

// Scenario 1: Entity doesn't exist
DELETE /articles/99999 (doesn't exist)
→ 404 Not Found (entity not found in getOne step)

// Scenario 2: Entity exists but ACL denies deletion
DELETE /articles/5 (exists but conditions don't match)
→ 403 Forbidden (entity loaded, ACL check failed)

// Why different from getOne?
// - getOne: ACL filtering at DB level (appears as "not found")
// - deleteOne: ACL check after loading entity (explicit denial)

Why two-stage check?

deleteOne needs access to entity data for @input templates:

// This rule needs entity data:
{
  conditions: {
    authorId: '${@input.authorId}',        // From entity
    status: { $ne: 'published' },           // From entity
    createdAt: { $gt: '${@input.yesterday}' } // Computed from entity
  }
}

// Flow:
// 1. Fetch entity (no ACL filtering)
// 2. updateWithInput(entity) - materialize with entity data
// 3. Check can('deleteOne', entity) - evaluate conditions
// 4. Delete if allowed

Important: deleteOne uses the same error handling as getAll:

  • Invalid ACL rules → Production: 403, Development: 500
  • Same recommendations apply (test rules, use simple conditions, monitor logs)

postOne - Create New Entity

Flow:

POST /posts
↓
1. AclGuard checks: can('postOne', 'Post')
2. ORM Proxy intercepts ormService.postOne(inputData)
3. Load relationships (if provided in request)
4. Build entity from attributes + loaded relationships
5. Two-stage check with @input support:
   - updateWithInput(entity) - materialize rules with input data
   - Check entity-level: can('postOne', subject('Post', entity))
   - Check field-level: for each changed field → can('postOne', entity, field)
6. If denied → 403 Forbidden (entity or field)
7. If allowed → execute create
8. Return: created entity with ID

Three ACL Scenarios:

1. No conditions, no field restrictions (admin)

// Rule:
{
  action: 'postOne',
  subject: 'Article',
  // No conditions = can create with any data
  // No fields = can set any fields
}

// Result: Can create articles with any author
// POST /articles
// body: { authorId: 123, status: 'published', ... }
// → ✅ Success (201)
//
// body: { authorId: 456, status: 'published', ... }
// → ✅ Success (201)

2. Conditions with @input (moderator)

// Rule: Can only create articles where they are the author
{
  action: 'postOne',
  subject: 'Article',
  conditions: {
    authorId: '${@input.authorId}',  // Must match input authorId
  }
}

// Scenario A: Creating with own author
// POST /articles (currentUserId = 123)
// body: { authorId: 123, status: 'published', ... }
// → Build entity → updateWithInput({ authorId: 123, ... })
// → Materialize: authorId: 123 (from @input)
// → Check: can('postOne', entity) → ✅ authorId matches
// → ✅ Success (201)

// Scenario B: Creating with different author
// POST /articles (currentUserId = 123)
// body: { authorId: 456, status: 'published', ... }
// → Build entity → updateWithInput({ authorId: 456, ... })
// → Check: can('postOne', entity) → ❌ authorId doesn't match (456 != 123)
// → ❌ 403 Forbidden
// {
//   "errors": [{
//     "code": "forbidden",
//     "message": "not allow \"postOne\"",
//     "path": ["action"]
//   }]
// }

3. Conditions + field restrictions (user)

// Rule: Can create draft articles, only specific fields allowed
{
  action: 'postOne',
  subject: 'Article',
  conditions: {
    authorId: '${@input.authorId}',  // Must be own article
    status: 'draft'                   // Must be draft
  },
  fields: ['title', 'content', 'authorId', 'status']  // Only these fields
}

// Scenario A: Create draft with allowed fields
// POST /articles (currentUserId = 123)
// body: { authorId: 123, status: 'draft', title: 'Test', content: '...' }
// → Build entity → updateWithInput(entity)
// → Check entity: can('postOne', entity) → ✅ Conditions match
// → Check fields:
//   - can('postOne', entity, 'authorId') → ✅ In fields list
//   - can('postOne', entity, 'status') → ✅ In fields list
//   - can('postOne', entity, 'title') → ✅ In fields list
//   - can('postOne', entity, 'content') → ✅ In fields list
// → ✅ Success (201)

// Scenario B: Try to create published article
// POST /articles (currentUserId = 123)
// body: { authorId: 123, status: 'published', title: 'Test' }
// → Build entity → updateWithInput(entity)
// → Check entity: can('postOne', entity) → ❌ status != 'draft'
// → ❌ 403 Forbidden (entity-level)

// Scenario C: Try to set forbidden field
// POST /articles (currentUserId = 123)
// body: { authorId: 123, status: 'draft', title: 'Test', publishedAt: new Date() }
// → Build entity → updateWithInput(entity)
// → Check entity: can('postOne', entity) → ✅ Conditions match
// → Check fields:
//   - can('postOne', entity, 'authorId') → ✅ Allowed
//   - can('postOne', entity, 'status') → ✅ Allowed
//   - can('postOne', entity, 'title') → ✅ Allowed
//   - can('postOne', entity, 'publishedAt') → ❌ NOT in fields list!
// → ❌ 403 Forbidden (field-level)
// {
//   "errors": [{
//     "code": "forbidden",
//     "message": "not allow to set field \"publishedAt\"",
//     "path": ["data", "attributes", "publishedAt"]
//   }]
// }

Key Points:

  • Two-stage check: Entity-level check + field-level check for each input field
  • @input support: Can use ${@input.field} in conditions (access to input data)
  • Field-level restrictions: Each input field checked individually with can(action, entity, field)
  • Relationships loaded: If relationships provided, they are loaded and merged with attributes
  • ⚠️ 403 on denial: Returns 403 Forbidden with specific error (entity or field)
  • ⚠️ No __current support: Cannot compare old/new values (no existing entity context)
  • ⚠️ Changed fields only: Only fields present in input (attributes + relationships) are checked

Entity-level vs Field-level errors:

// Entity-level error (conditions don't match):
{
  "errors": [{
    "code": "forbidden",
    "message": "not allow \"postOne\"",
    "path": ["action"]
  }]
}

// Field-level error (specific field not allowed):
{
  "errors": [{
    "code": "forbidden",
    "message": "not allow to set field \"publishedAt\"",
    "path": ["data", "attributes", "publishedAt"]  // Precise location
  }]
}

Why two checks?

postOne needs fine-grained control:

  1. Entity-level: Validate overall entity state (e.g., "must be draft", "must be own article")
  2. Field-level: Validate which fields user can set (e.g., "can't set publishedAt", "can't set adminOnly fields")

This allows rules like: "Users can create draft posts but can't set publishedAt or moderatorNotes fields"

Important: postOne uses the same error handling as getAll:

  • Invalid ACL rules → Production: 403, Development: 500
  • Same recommendations apply (test rules, use simple conditions, monitor logs)

patchOne - Update Single Entity

Flow:

PATCH /posts/:id
↓
1. AclGuard checks: can('patchOne', 'Post')
2. ORM Proxy intercepts ormService.patchOne(id, inputData)
3. Fetch entity from database (with ACL conditions for access check)
4. If not found → 404 Not Found
5. Load relationships (if provided in request)
6. Detect changed fields (compare old vs new values)
7. Build entity for check with __current:
   - Root level: NEW values (after applying changes)
   - __current: OLD values (from database)
8. Two-stage check with @input + __current support:
   - updateWithInput(entityForCheck) - materialize rules with old/new data
   - Check entity-level: can('patchOne', subject('Post', entityForCheck))
   - Check field-level: for each changed field → can('patchOne', entityForCheck, field)
9. If denied → 403 Forbidden (entity or field)
10. If allowed → execute update
11. Return: updated entity

The __current Magic 🪄

patchOne has a unique feature: access to both old and new values simultaneously:

// Entity structure during ACL check:
{
  ...newValues,           // Root level: values AFTER update
  __current: oldValues    // Nested: values BEFORE update (from DB)
}

This enables rules like:

  • "Allow changing status from draft to review, but not to published"
  • "Allow removing only yourself from coAuthors"
  • "Allow increasing price, but not decreasing it"

⚠️ Yes, this looks a bit hacky (we know! 😅), but after extensive brainstorming, this was the cleanest solution we found for comparing old/new values in CASL rules. If you have a better idea, we'd love to hear it! Open a GitHub discussion or submit a PR! 🙏

Three ACL Scenarios:

1. No conditions, no field restrictions (admin)

// Rule:
{
  action: 'patchOne',
  subject: 'Article',
  // No conditions = can update any article
  // No fields = can update any fields
}

// Result: Can update any article, any fields
// PATCH /articles/1
// body: { title: 'New title', status: 'published' }
// → ✅ Success (200)

2. Field restrictions + value validation (moderator)

// Rule: