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 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nestjs-pipeline/casl

v0.1.1

Published

CASL authorization behavior for @nestjs-pipeline/core — ABAC with role-based capability trees

Readme

@nestjs-pipeline/casl

CASL authorization behavior for @nestjs-pipeline/core — ABAC (Attribute-Based Access Control) with role-based capability trees.

Features

  • ABAC + Roles: Define roles with predefined capability sets, plus per-user overrides
  • Capability tree strings: Compact subject|action|conditions[|fields] format for JWT/cookie transport
  • Condition interpolation: Template placeholders (${id}, {{ tenantId }}, {{ tenantSchema }}) resolved against user context
  • Pluggable providers: Bring your own role provider (DB, YAML, static) and user capability provider
  • Pipeline integration: Works as a @UsePipeline behavior on commands, queries, and events
  • Inline rules: Declare permission requirements directly on the handler via CaslBehaviorOptions.rules
  • Configurable subject context paths: Resolve nested session/user payloads explicitly via subjectContextPaths
  • Global field-level checks: Enforce field permissions from request payloads with defaultFieldsFromRequest

CASL is tenant-model agnostic: conditions can target any tenant scope field (tenantId, tenantSchema, organization id, etc.) available in request/user context.

Installation

pnpm add @nestjs-pipeline/casl @casl/ability

Quick Start

1. Define roles

import { StaticRoleProvider } from '@nestjs-pipeline/casl';

const roleProvider = new StaticRoleProvider([
  {
    name: 'admin',
    capabilities: ['all|manage|*'], // manage everything
  },
  {
    name: 'author',
    capabilities: [
      'Post|read|*',
      'Post|create|*',
      'Post|update|{"authorId":"${id}"}',  // own posts only
      'Post|delete|{"authorId":"${id}"}',
      'Comment|read|*',
      'Comment|create|*',
    ],
  },
  {
    name: 'viewer',
    capabilities: ['Post|read|*', 'Comment|read|*'],
  },
]);
const roleProvider = new StaticRoleProvider([
  {
    name: 'tenant-admin',
    capabilities: [
      'User|manage|{"tenantId":"${user.tenantId}"}',
      'Project|manage|{"tenantId":"${user.tenantId}"}',
      'Invoice|read|{"tenantId":"${user.tenantId}"}',
      '!Invoice|delete|*', // cannot delete invoices even within own tenant
    ],
  },
  {
    name: 'project-manager',
    capabilities: [
      'Project|read|{"tenantId":"${user.tenantId}"}',
      // Update projects they belong to, only in active/planning status
      'Project|update|{"tenantId":"${user.tenantId}","members":{"$elemMatch":{"userId":"${user.id}"}},"status":{"$in":["active","planning"]}}',
      // Manage own tasks, read all tasks in tenant
      'Task|manage|{"tenantId":"${user.tenantId}","assigneeId":"${user.id}"}',
      'Task|read|{"tenantId":"${user.tenantId}"}',
      // Delete only own draft comments
      'Comment|read|{"tenantId":"${user.tenantId}"}',
      'Comment|create|*',
      'Comment|delete|{"authorId":"${user.id}","status":"draft"}',
    ],
  },
  {
    name: 'auditor',
    capabilities: [
      // Read-only with restricted fields on User (4th segment)
      'User|read|{"tenantId":"${user.tenantId}"}|id,name,email,role',
      'Project|read|{"tenantId":"${user.tenantId}"}',
      'Invoice|read|{"tenantId":"${user.tenantId}"}',
      'AuditLog|read|{"tenantId":"${user.tenantId}"}',
      '!User|update|*',
    ],
  },
]);

2. Register the module

import { CaslModule, CaslBehavior } from '@nestjs-pipeline/casl';
import { PipelineModule } from '@nestjs-pipeline/core';

@Module({
  imports: [
    CaslModule.forRoot({
      roleProvider: { useFactory: () => roleProvider },
      subjectContextPaths: ['sessionUser'],
      defaultFieldsFromRequest: {
        User: ['username', 'department', 'email'],
      },
      userCapabilityProvider: DatabaseUserCapabilityProvider,
    }),
    PipelineModule.forRoot({
      globalBehaviors: {
        scope: 'all',
        before: [CaslBehavior],
      },
    }),
  ],
})
export class AppModule {}

subjectContextPaths is explicit and required at module registration. CASL does not assume a built-in request path such as sessionUser.

3. Declare rules on handlers

Permission requirements are declared inline via CaslBehaviorOptions.rules on the handler's @UsePipeline. This keeps rules co-located with the handler.

import { CaslBehavior } from '@nestjs-pipeline/casl';

// Simple command — user must be able to create Posts
@CommandHandler(CreatePostCommand)
@UsePipeline([CaslBehavior, {
  rules: [{ action: 'create', subject: 'Post' }],
}])
class CreatePostHandler implements ICommandHandler<CreatePostCommand> {
  async execute(command: CreatePostCommand) { /* ... */ }
}

// Simple query — user must be able to read Posts
@QueryHandler(GetPostQuery)
@UsePipeline([CaslBehavior, {
  rules: [{ action: 'read', subject: 'Post' }],
}])
class GetPostHandler implements IQueryHandler<GetPostQuery> {
  async execute(query: GetPostQuery) { /* ... */ }
}
// Cross-resource command — user must be able to update Order.status AND create AuditLog
@CommandHandler(FulfillOrderCommand)
@UsePipeline([CaslBehavior, {
  subjectFromRequest: 'Order',
  rules: [
    { action: 'update', subject: 'Order', field: 'status' },
    { action: 'create', subject: 'AuditLog' },
  ],
}])
class FulfillOrderHandler { /* ... */ }

// Multi-tenant query — must read both Project and its Tasks
@QueryHandler(GetProjectWithTasksQuery)
@UsePipeline([CaslBehavior, {
  rules: [
    { action: 'read', subject: 'Project' },
    { action: 'read', subject: 'Task' },
  ],
}])
class GetProjectWithTasksHandler { /* ... */ }

// Admin-only purge command
@CommandHandler(PurgeDeletedUsersCommand)
@UsePipeline([CaslBehavior, {
  rules: [{ action: 'manage', subject: 'all' }],
}])
class PurgeDeletedUsersHandler { /* ... */ }

// Sensitive data — must be able to read User AND the salary field specifically
@QueryHandler(GetPayrollReportQuery)
@UsePipeline([CaslBehavior, {
  rules: [
    { action: 'read', subject: 'User' },
    { action: 'read', subject: 'User', field: 'salary' },
  ],
}])
class GetPayrollReportHandler { /* ... */ }

// Event authorization — only users who can 'publish' a Post
@UsePipeline([CaslBehavior, {
  rules: [{ action: 'publish', subject: 'Post' }],
}])
class PostPublishedHandler { /* ... */ }

4. Handler options with @UsePipeline

CaslBehaviorOptions controls how permissions are checked:

import { CaslBehavior } from '@nestjs-pipeline/casl';

// ── Type-level check (default) ──────────────────────────────────────────
// "Can this user read Posts at all?" — no conditions are evaluated
// against the query payload.
@QueryHandler(GetPostQuery)
@UsePipeline([CaslBehavior, {
  rules: [{ action: 'read', subject: 'Post' }],
}])
class GetPostHandler { /* ... */ }

// ── Instance-level check with subjectFromRequest ────────────────────────
// CASL evaluates conditions against the command payload.
// If the capability is Post|update|{"authorId":"${user.id}"}, CASL checks
// that command.authorId matches the current user's id.
@CommandHandler(UpdatePostCommand)
@UsePipeline([CaslBehavior, {
  subjectFromRequest: 'Post',
  rules: [{ action: 'update', subject: 'Post' }],
}])
class UpdatePostHandler { /* ... */ }

// ── Multi-tenant with complex conditions ────────────────────────────────
// Capability: Project|update|{"tenantId":"${user.tenantId}","status":{"$in":["active","planning"]}}
// subjectFromRequest makes CASL check tenantId AND status on the command.
@CommandHandler(UpdateProjectCommand)
@UsePipeline([CaslBehavior, {
  subjectFromRequest: 'Project',
  rules: [{ action: 'update', subject: 'Project' }],
}])
class UpdateProjectHandler { /* ... */ }

// ── Nested session/user payloads ───────────────────────────────────────
// Some apps keep actor context under a nested request object instead of at the root.
// Configure the path explicitly so CASL can merge that payload for condition checks.
@CommandHandler(UpdateUserCommand)
@UsePipeline([CaslBehavior, {
  subjectFromRequest: 'User',
  subjectContextPaths: ['auth.session.user'],
  rules: [{ action: 'update', subject: 'User' }],
}])
class UpdateUserHandler { /* ... */ }

// ── Field-level update enforcement from request payload ────────────────
// fieldsFromRequest does not grant access. It only tells CASL which changed
// fields to validate against the user's ability.
@CommandHandler(UpdateUserProfileCommand)
@UsePipeline([CaslBehavior, {
  subjectFromRequest: 'User',
  fieldsFromRequest: ['username', 'department', 'email'],
  rules: [{ action: 'update', subject: 'User' }],
}])
class UpdateUserProfileHandler { /* ... */ }

// ── Cross-resource command ──────────────────────────────────────────────
// User must update Order.status AND create AuditLog.
// subjectFromRequest: 'Order' checks conditions on the Order requirement;
// the AuditLog requirement is type-level only.
@CommandHandler(FulfillOrderCommand)
@UsePipeline([CaslBehavior, {
  subjectFromRequest: 'Order',
  rules: [
    { action: 'update', subject: 'Order', field: 'status' },
    { action: 'create', subject: 'AuditLog' },
  ],
}])
class FulfillOrderHandler { /* ... */ }

// ── Public endpoint with skipCheck ──────────────────────────────────────
// No rules needed. The ability is built and stored for
// downstream use, but no access check is performed.
@QueryHandler(ListPostsQuery)
@UsePipeline([CaslBehavior, { skipCheck: true }])
class ListPostsHandler implements IQueryHandler<ListPostsQuery> {
  async execute(query: ListPostsQuery, context: IPipelineContext) {
    const ability = context.items.get(CASL_ABILITY_KEY) as AppAbility;
    const includeDrafts = ability?.can('read', 'DraftPost');
    // Tailor the response based on what the user can see
  }
}

5. Set user context

Place the user context in context.items before the CASL behavior runs (e.g., in an authentication middleware/behavior):

import { CASL_USER_CONTEXT_KEY } from '@nestjs-pipeline/casl';

// In your auth behavior or middleware:
context.items.set(CASL_USER_CONTEXT_KEY, {
  id: user.id,
  tenantId: user.tenantId,
  // ...any properties needed for condition interpolation
});

Or implement IUserContextResolver for custom extraction.

When your request keeps actor/session data under a nested object, pair this with subjectContextPaths so both user resolution and subject condition checks read from the same configured path.

Capability String Format

[!]subject|action|conditions[|fields]

| Part | Description | Default/Wildcard | |------------|----------------------------------------|------------------------| | subject | Entity type (e.g., Post, User) | all → any subject | | action | Verb (e.g., read, create) | manage → any action | | conditions | MongoDB-style JSON conditions | * → none | | fields | Comma-separated field names | omitted or * → all | | ! prefix | Inverted (deny) rule | — |

Examples

| String | Meaning | |-----------------------------------------|----------------------------------| | all\|manage\|* | Full access to everything | | Post\|read\|* | Read any post | | Post\|manage\|* | Manage all posts | | Post\|update\|{"authorId":"${id}"} | Update own posts only | | Post\|read\|*\|title,body,status | Read only title, body, status | | !Post\|delete\|* | Cannot delete any post | | Post\|update\|{"authorId":"${id}","status":{"$in":["draft","review"]}} | Update own posts only in draft/review | | Document\|read\|{"tenantId":"${user.tenantId}","visibility":{"$ne":"private"}} | Read tenant docs that are not private | | Order\|update\|{"assigneeId":"${id}","status":{"$nin":["completed","cancelled"]}} | Update own orders unless completed/cancelled |

Per-User Overrides

Implement IUserCapabilityProvider to add capabilities beyond a user's role:

@Injectable()
export class DbUserCapabilityProvider implements IUserCapabilityProvider {
  async getUserCapabilities(user: CaslUserContext): Promise<UserCapabilities> {
    const userRecord = await this.db.findUser(user.id);
    return {
      roles: userRecord.roles,                     // ['author']
      additionalCapabilities: userRecord.extraCaps, // e.g., ['User|invite|*']
      deniedCapabilities: userRecord.deniedCaps,    // e.g., ['!Post|delete|*']
    };
  }
}

PostgreSQL-backed providers

-- Central entity: every permission is a Capability row
CREATE TABLE capabilities (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  subject    TEXT NOT NULL,
  action     TEXT NOT NULL,
  conditions JSONB,
  inverted   BOOLEAN NOT NULL DEFAULT false,
  reason     TEXT,
  fields     TEXT[]
);
CREATE INDEX idx_capabilities_subject ON capabilities (subject);
CREATE INDEX idx_capabilities_action ON capabilities (action);
CREATE UNIQUE INDEX idx_capabilities_unique ON capabilities (subject, action, conditions);

-- Roles
CREATE TABLE roles (
  id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT UNIQUE NOT NULL
);

-- Role ↔ Capability junction
CREATE TABLE role_capabilities (
  role_id       UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  capability_id UUID NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, capability_id)
);

-- User ↔ Role junction
CREATE TABLE user_roles (
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, role_id)
);

-- Per-user additional capabilities
CREATE TABLE user_additional_capabilities (
  user_id       UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  capability_id UUID NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, capability_id)
);

-- Per-user denied capabilities
CREATE TABLE user_denied_capabilities (
  user_id       UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  capability_id UUID NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, capability_id)
);

See demo-seed.sql for complete seed data with all 7 roles (admin, viewer, author, tenant-admin, project-manager, auditor, support-agent), 25+ capabilities, role assignments, and example user/override templates.

@Injectable()
export class PgRoleProvider implements IRoleProvider {
  constructor(private readonly pool: Pool) {}

  async getRoles(names?: string[]): Promise<RoleDefinition[]> {
    const where = names ? 'WHERE r.name = ANY($1)' : '';
    const params = names ? [names] : [];
    const { rows } = await this.pool.query(
      `SELECT r.name,
              json_agg(json_build_object(
                'subject', c.subject, 'action', c.action,
                'conditions', c.conditions, 'inverted', c.inverted,
                'reason', c.reason, 'fields', c.fields
              )) AS capabilities
       FROM roles r
       JOIN role_capabilities rc ON rc.role_id = r.id
       JOIN capabilities c ON c.id = rc.capability_id
       ${where}
       GROUP BY r.id`,
      params,
    );
    return rows;
  }
}

@Injectable()
export class PgUserCapabilityProvider implements IUserCapabilityProvider {
  constructor(private readonly pool: Pool) {}

  async getUserCapabilities(user: CaslUserContext): Promise<UserCapabilities> {
    const rolesResult = await this.pool.query(
      'SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = $1',
      [user.id],
    );

    const additionalResult = await this.pool.query(
      `SELECT c.subject, c.action, c.conditions, c.inverted, c.reason, c.fields
       FROM user_additional_capabilities uac
       JOIN capabilities c ON c.id = uac.capability_id
       WHERE uac.user_id = $1`,
      [user.id],
    );

    const deniedResult = await this.pool.query(
      `SELECT c.subject, c.action, c.conditions, c.inverted, c.reason, c.fields
       FROM user_denied_capabilities udc
       JOIN capabilities c ON c.id = udc.capability_id
       WHERE udc.user_id = $1`,
      [user.id],
    );

    return {
      roles: rolesResult.rows.map((r) => r.name),
      additionalCapabilities: additionalResult.rows,
      deniedCapabilities: deniedResult.rows,
    };
  }
}

YAML-backed roles

For projects that don't need a database, roles can be defined in a YAML file and loaded at startup. See demo-roles.yml for a complete example with basic roles, multi-tenant roles, field restrictions, and deny rules.

import { Injectable } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { parse } from 'yaml';
import type { IRoleProvider, RoleDefinition, Capability } from '@nestjs-pipeline/casl';

interface YamlCapabilityObject {
  subject: string;
  action: string;
  conditions?: Record<string, unknown>;
  inverted?: boolean;
  reason?: string;
  fields?: string[];
}

interface YamlRolesFile {
  roles: Array<{
    name: string;
    capabilities: Array<string | YamlCapabilityObject>;
  }>;
}

@Injectable()
export class YamlRoleProvider implements IRoleProvider {
  private readonly roles: RoleDefinition[];

  constructor(filePath: string) {
    const raw = readFileSync(resolve(filePath), 'utf-8');
    const parsed = parse(raw) as YamlRolesFile;

    this.roles = parsed.roles.map((r) => ({
      name: r.name,
      capabilities: r.capabilities.map((cap) => {
        if (typeof cap === 'string') return cap; // CapabilityString
        // Object form → Capability
        const result: Capability = { subject: cap.subject, action: cap.action };
        if (cap.conditions) result.conditions = cap.conditions;
        if (cap.inverted) result.inverted = true;
        if (cap.reason) result.reason = cap.reason;
        if (cap.fields) result.fields = cap.fields;
        return result;
      }),
    }));
  }

  getRoles(names?: string[]): RoleDefinition[] {
    if (!names) return this.roles;
    return this.roles.filter((r) => names.includes(r.name));
  }
}
@Module({
  imports: [
    CaslModule.forRoot({
      roleProvider: {
        useFactory: () => new YamlRoleProvider('./config/roles.yml'),
      },
      userContextResolver: JwtUserContextResolver,
      // Required at runtime — without this, handlers with rules will throw.
      // Implement IUserCapabilityProvider to map the current user to their role names.
      userCapabilityProvider: YamlUserCapabilityProvider,
    }),
    PipelineModule.forRoot({
      globalBehaviors: { scope: 'all', before: [CaslBehavior] },
    }),
  ],
})
export class AppModule {}
@Module({
  imports: [
    CaslModule.forRoot({
      roleProvider: {
        useFactory: (pool: Pool) => new PgRoleProvider(pool),
        inject: [Pool],
      },
      userContextResolver: JwtUserContextResolver,
      userCapabilityProvider: {
        useFactory: (pool: Pool) => new PgUserCapabilityProvider(pool),
        inject: [Pool],
      },
    }),
    PipelineModule.forRoot({
      globalBehaviors: { scope: 'all', before: [CaslBehavior] },
    }),
  ],
})
export class AppModule {}

Accessing the Ability Downstream

After the CASL behavior runs, the resolved ability is available in context.items:

import { CASL_ABILITY_KEY, AppAbility } from '@nestjs-pipeline/casl';

const ability = context.items.get(CASL_ABILITY_KEY) as AppAbility;
if (ability.can('publish', 'Post')) {
  // ...
}

License

See LICENSE and COMMERCIAL_LICENSE.txt.