From nestjs-clean-arch
This skill should be used when the user asks to "add authorization", "add CASL", "register subject", "protect route", "check policy", "add permissions", or "role-based access" to a new entity or feature in a NestJS Clean Architecture project.
npx claudepluginhub tuannguyen151/foxdemon-plugins --plugin nestjs-clean-archThis skill uses the workspace's default tool permissions.
CASL provides coarse, role-based authorization in this codebase. It answers one question: "Is this role allowed to perform this action on this resource type?" It does **not** answer "does this user own this record?" — that is the repository layer's responsibility.
Guides selection and implementation of authorization models including RBAC, ABAC, ACL, ReBAC, and policy-as-code for permission systems and access control design.
Provides ACL, RBAC, ABAC, ReBAC models, multi-tenancy patterns, and PHP implementations (Symfony Voters, Laravel Gates) for security audits and code generation.
Implements Role-Based Access Control (RBAC), permissions management, and authorization policies. Guides for Node.js, Python ABAC, Java Spring Security. Use for multi-tenant apps, APIs, admin dashboards.
Share bugs, ideas, or general feedback.
CASL provides coarse, role-based authorization in this codebase. It answers one question: "Is this role allowed to perform this action on this resource type?" It does not answer "does this user own this record?" — that is the repository layer's responsibility.
CASL layer → coarse check: Admin can manage all, User can read Task
Repository/use-case layer → ownership check: WHERE userId = :userId
Never collapse these two concerns. A @CheckPolicies decorator on a route only gates by role. Ownership filtering must always be enforced inside the use case or repository query.
| File | Role |
|---|---|
src/domain/services/ability.interface.ts | TAction, TSubject type unions — register new subjects here |
src/infrastructure/services/casl/casl-ability.factory.ts | Builds role-based ability rules |
src/infrastructure/services/casl/casl.module.ts | Exports IAbilityFactory for DI |
src/adapters/controllers/common/decorators/check-policies.decorator.ts | @CheckPolicies route decorator |
src/adapters/controllers/common/guards/policies.guard.ts | PoliciesGuard — evaluates policies before the handler |
PoliciesGuard runs after JwtAuthGuard in the NestJS middleware pipeline. By the time PoliciesGuard executes, the JWT token has already been validated and request.user is populated with the authenticated UserEntity.
Execution flow:
PoliciesGuard.canActivate() reads IPolicyHandler[] metadata attached to the route handler by @CheckPolicies via Reflector.get(CHECK_POLICIES_KEY, context.getHandler()).true — route is accessible to any authenticated user.IAbilityFactory.createForUser(request.user) to build a TAppAbility for the current user.IAbilityFactory.can(ability, handler). All must return true — it is an AND check, not OR.false and NestJS throws a 403 Forbidden.Always apply both guards on controllers that require authorization:
@UseGuards(JwtAuthGuard, PoliciesGuard)
Apply guards at the controller class level, not on individual methods, to avoid accidentally leaving routes unprotected. Routes with no @CheckPolicies decorator pass the guard automatically (empty handler list → allow), so public read-only endpoints within the same controller do not need explicit opt-out.
Add the new entity name as a string literal to TSubject in src/domain/services/ability.interface.ts:
// Before
export type TSubject = 'all' | 'Task'
// After (adding Comment)
export type TSubject = 'all' | 'Task' | 'Comment'
Use the domain entity name exactly as it appears (PascalCase string). This keeps the type union as the single source of truth.
Open src/infrastructure/services/casl/casl-ability.factory.ts and add rules inside createForUser:
if (user.role === RoleEnum.Admin) {
can('manage', 'all') // already covers everything
} else {
// Existing Task rules
can('read', 'Task')
can(['create', 'update'], 'Task')
// New Comment rules
can('read', 'Comment')
can(['create', 'update'], 'Comment')
// Regular users cannot delete — omit the rule
}
Follow the pattern: explicit allow rules only. Anything not granted is implicitly denied.
Available actions: 'manage' | 'create' | 'read' | 'update' | 'delete'
The special action 'manage' with subject 'all' grants unrestricted access — use only for Admin.
Apply @CheckPolicies on each route method. Pair it with JwtAuthGuard and PoliciesGuard on the controller class:
@Controller('comments')
@UseGuards(JwtAuthGuard, PoliciesGuard)
export class CommentsController {
@Get()
@CheckPolicies({ action: 'read', subject: 'Comment' })
async findAll(@Query() dto: GetListCommentsDto, @User('id') userId: number) {
const comments = await this.getListCommentsUseCase.execute({ ...dto, userId })
return comments.map((c) => new GetListCommentsPresenter(c))
}
@Post()
@CheckPolicies({ action: 'create', subject: 'Comment' })
async create(@Body() dto: CreateCommentDto, @User('id') userId: number) {
const comment = await this.createCommentUseCase.execute(dto, userId)
return new CreateCommentPresenter(comment)
}
@Patch(':id')
@CheckPolicies({ action: 'update', subject: 'Comment' })
async update(
@User('id') userId: number,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateCommentDto,
) {
return this.updateCommentUseCase.execute({ id, userId }, dto)
}
@Delete(':id')
@CheckPolicies({ action: 'delete', subject: 'Comment' })
async remove(@Param('id', ParseIntPipe) id: number) {
return this.deleteCommentUseCase.execute({ id })
}
}
Each route declares exactly the action + subject it requires. Lean controllers — no if/else logic.
Import CaslModule in the feature's NestJS module so PoliciesGuard can inject IAbilityFactory:
@Module({
imports: [TypeOrmModule.forFeature([Comment]), CaslModule, ExceptionsModule],
controllers: [CommentsController],
providers: [
{ provide: ICommentRepository, useClass: CommentRepository },
GetListCommentsUseCase,
CreateCommentUseCase,
UpdateCommentUseCase,
DeleteCommentUseCase,
],
})
export class CommentsModule {}
Without CaslModule in imports, PoliciesGuard will throw a DI error at runtime.
Test the ability factory directly — no HTTP layer or database needed. The CaslAbilityFactory has no dependencies, so the test module is minimal. Verify each role × action × subject combination that matters for the feature.
Testing structure:
describe block per role (Admin user, Regular user)describe blocks per subject (Task, Comment)action + subject combinationimport { Test, type TestingModule } from '@nestjs/testing'
import { createUserStub } from 'test/stubs/user.stub'
import { RoleEnum } from '@domain/entities/user.entity'
import { CaslAbilityFactory } from '@infrastructure/services/casl/casl-ability.factory'
describe('CaslAbilityFactory', () => {
let factory: CaslAbilityFactory
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CaslAbilityFactory],
}).compile()
factory = module.get<CaslAbilityFactory>(CaslAbilityFactory)
})
describe('Admin user', () => {
it('should allow manage all', () => {
const ability = factory.createForUser(createUserStub({ role: RoleEnum.Admin }))
expect(factory.can(ability, { action: 'manage', subject: 'all' })).toBe(true)
})
})
describe('Regular user', () => {
it('should allow read Comment', () => {
const ability = factory.createForUser(createUserStub())
expect(factory.can(ability, { action: 'read', subject: 'Comment' })).toBe(true)
})
it('should NOT allow delete Comment', () => {
const ability = factory.createForUser(createUserStub())
expect(factory.can(ability, { action: 'delete', subject: 'Comment' })).toBe(false)
})
})
})
Place the spec file at test/infrastructure/services/casl/casl-ability.factory.spec.ts, extending the existing test file rather than creating a separate one. Add new describe blocks for each new subject.
| Pattern | Admin | Regular User | Use case |
|---|---|---|---|
| Full CRUD | manage all | read, create, update | Most resources |
| Read-only public | manage all | read | Catalog, announcements |
| Admin-only delete | manage all | read, create, update | Comments, posts |
| Admin-only write | manage all | read | Settings, config |
| No regular access | manage all | (no rules) | Admin-only resources |
This distinction is critical and must be understood before adding any CASL rule.
CASL Guard Use Case / Repository
───────────────────────────── ──────────────────────────────────────
can('update', 'Comment') WHERE id = :id AND userId = :userId
↓ passes (role-level check) ↓ throws NotFoundException if no row
CASL answers: "can users with this role perform this action on this resource type?"
The repository answers: "does this specific record belong to this user?"
A regular user who passes @CheckPolicies({ action: 'update', subject: 'Comment' }) has only proven their role grants update access. They can still be blocked at the database query if the userId filter finds no matching row.
Never skip the userId filter in queries just because CASL already checked the role. The two checks are complementary — removing either creates a security gap.
Concrete example of a safe update use case:
// get-detail-comment.use-case.ts
async execute({ id, userId }: { id: number; userId: number }) {
const comment = await this.commentRepository.findByIdAndUserId(id, userId)
if (!comment) {
throw new NotFoundException('Comment not found')
}
return comment
}
The repository query includes userId in the WHERE clause. If another user's id is passed, the query returns nothing and the use case throws NotFoundException — no data leakage, no unauthorized update.
When adding CASL authorization to a new entity, verify each item:
TSubject union in ability.interface.tscan('manage', 'all') already covers it — no change neededcan(...) rules added for allowed actions@CheckPolicies applied on every protected controller method@UseGuards(JwtAuthGuard, PoliciesGuard) on the controller classCaslModule in imports of the feature moduleuserId filter for ownership enforcementreferences/casl-examples.md — Complete step-by-step walkthrough adding CASL for a Comment entity, including the full updated factory file, controller, module, and test suite.