CASL
The query service is CASL-agnostic until an app wires an accessibility resolver into it. The CASL helpers cover route policies, Prisma read filters, and field-level DTO filtering; applications retain ownership of their ability factories and subjects.
pnpm add @casl/ability @casl/prismaService Wiring
The complete runnable service from the Books API is included below. Its super call wires the CASL subject and the action-aware Prisma resolver into every ability-aware read method. It also shows that a conditional write rule must be checked against the concrete record before mutating it.
import { AbilityBuilder, PureAbility, subject } from '@casl/ability';
import { createPrismaAbility } from '@casl/prisma';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
createCaslAccessibleWhere,
QueryService,
type BaseDelegateTypeMap,
type QueryOptionsMap,
} from '@querry-kit/nest';
import { BookDelegate, type BookInclude, type BookWhereInput } from './book.delegate.js';
import type { BookModel, CreateBookDTO, UpdateBookDTO } from './book.dto.js';
export enum BookAction {
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
export const BookSubject = 'Book';
export type DemoAbility = PureAbility<[BookAction, any], any>;
/**
* Creates the ability used by the in-memory example API.
*
* @returns {DemoAbility} Ability that reads published books and updates drafts only.
*/
export function createDemoAbility(): DemoAbility {
const { can, build } = new AbilityBuilder<DemoAbility>(createPrismaAbility);
can(BookAction.Read, BookSubject, { published: true });
can(BookAction.Create, BookSubject);
can(BookAction.Update, BookSubject, { published: false });
can(BookAction.Delete, BookSubject);
return build();
}
export interface BookTypeMap extends BaseDelegateTypeMap {
select: Partial<Record<keyof BookModel, boolean>>;
include: BookInclude;
whereInput: BookWhereInput;
orderByWithRelationInput: Partial<Record<keyof BookModel, 'asc' | 'desc'>>;
whereUniqueInput: Pick<BookModel, 'id'>;
scalarFieldEnum: keyof BookModel;
aggregateInputType: { _count?: true };
}
@Injectable()
export class BooksService extends QueryService<
BookDelegate,
BookTypeMap,
BookDelegate,
QueryOptionsMap<BookTypeMap>,
DemoAbility,
typeof BookSubject
> {
private readonly delegate: BookDelegate;
constructor() {
const delegate = new BookDelegate();
super(delegate, {
subject: BookSubject,
accessibleWhere: createCaslAccessibleWhere<DemoAbility, typeof BookSubject, BookAction>({
action: BookAction.Read,
}),
});
this.delegate = delegate;
}
async create(data: CreateBookDTO, query: QueryOptionsMap<BookTypeMap>['findById'] = {}): Promise<BookModel> {
return this.delegate.create({
data: {
title: data.title,
isbn: data.isbn,
authorId: data.authorId,
tagIds: data.tagIds,
},
include: query.include,
});
}
async update(
id: string,
data: UpdateBookDTO,
query: QueryOptionsMap<BookTypeMap>['findById'] = {},
ability?: DemoAbility,
): Promise<BookModel> {
const existing = await this.delegate.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('Book not found.');
}
if (!ability?.can(BookAction.Update, subject(BookSubject, existing))) {
throw new ForbiddenException('Insufficient permissions.');
}
const updated = await this.delegate.update({
where: { id },
data,
include: query.include,
});
if (!updated) {
throw new NotFoundException('Book not found.');
}
return updated;
}
async remove(id: string, query: QueryOptionsMap<BookTypeMap>['findById'] = {}): Promise<BookModel> {
const deleted = await this.delegate.delete({ where: { id }, include: query.include });
if (!deleted) {
throw new NotFoundException('Book not found.');
}
return deleted;
}
}For a Prisma service, replace BookDelegate with your generated delegate type (for example typeof PrismaService.prototype.project) while retaining the same subject and accessibleWhere options:
import { createCaslAccessibleWhere } from '@querry-kit/nest/casl';
import type { QueryOptionsMap } from '@querry-kit/nest/dto';
import { QueryService } from '@querry-kit/nest/query-service';
export class ProjectsService extends QueryService<
typeof PrismaService.prototype.project,
ProjectTypeMap,
typeof PrismaService.prototype.project,
QueryOptionsMap<ProjectTypeMap>,
AppAbility,
'Project'
> {
constructor(prisma: PrismaService) {
super(prisma.project, {
subject: 'Project',
accessibleWhere: createCaslAccessibleWhere<AppAbility, 'Project'>({ action: 'read' }),
});
}
}Pass the current ability when calling protected read methods:
const result = await this.projectsService.query(query, req.ability);CASL is optional at the controller layer too. ResourceQuery.query and ResourceQuery.findById accept ability, but they do not require it.
When an ability-aware DTO mapper or response policy needs relations, set them as endpoint-required includes:
return ResourceQuery.query({
service: this.projectsService,
query,
schema: ProjectDTO,
ability: req.ability,
include: { members: true },
map: (project, ability) => ProjectDTO.fromModel(project, ability),
});Client include parameters extend those required includes, and fields adds any relation includes needed for projection.
When query receives both an ability and caller filters, QueryService merges them with AND so the access rule stays mandatory.
{
AND: [
{ members: { some: { userId: currentUser.id } } },
{ archived: false }
]
}Policy Decorator and Guard
Use CheckPolicies to attach route-level policy handlers and PoliciesGuard to evaluate them against request.ability.
@Get()
@CheckPolicies((ability) => ability.can('read', 'Project'))
async query() {}PoliciesGuard throws a Nest ForbiddenException when no ability is present or any policy returns false.
DTO Field Filtering
Use filterCaslFields at the end of a DTO mapper when field permissions should affect the JSON response. It returns a shallow copy, preserves the DTO prototype, and never mutates the mapper's DTO.
import { filterCaslFields } from '@querry-kit/nest/casl';
class ProjectDTO {
static fromModel(project: Project, ability?: AppAbility): ProjectDTO {
const dto = Object.assign(new ProjectDTO(), {
id: project.id,
name: project.name,
internalBudget: project.internalBudget,
});
return filterCaslFields(dto, 'Project', ability);
}
}The helper first checks the special CASL field all. If it is not allowed, it checks every enumerable DTO field individually. Conditional CASL rules receive the DTO as a CASL subject, so they can inspect its values.
DTO field filtering protects the serialized response only. Keep passing the ability to QueryService or ResourceQuery as well so the Prisma query itself is restricted.
The default action is read. Applications that use a differently cased or enum-backed action must pass it explicitly:
return filterCaslFields(dto, RoleSubject.PROJECT, ability, { action: RoleAction.READ });CASL Prisma Versions
createCaslAccessibleWhere supports CASL Prisma subject maps and the newer accessibleBy(...).ofType(subject) shape. The package does not export an ability factory; applications keep their own CASL module, subjects, actions, and user context.