NestJS CASL 권한 관리란?
CASL(Isomorphic Authorization)은 “누가 무엇을 할 수 있는가”를 정의하는 JavaScript 권한 관리 라이브러리입니다. 단순한 Role 기반(RBAC)을 넘어 속성 기반 접근 제어(ABAC)까지 지원하며, “관리자는 모든 글을 삭제할 수 있다”, “사용자는 자신의 글만 수정할 수 있다” 같은 세밀한 규칙을 선언적으로 표현합니다. 이 글에서는 NestJS에 CASL을 통합하고, AbilityFactory, Guard, Policy Handler 패턴까지 실무에서 바로 쓸 수 있는 심화 내용을 다룹니다.
설치와 기본 설정
npm install @casl/ability
먼저 액션과 주체(Subject)를 정의합니다:
// casl/actions.ts
export enum Action {
Manage = 'manage', // 모든 액션 (와일드카드)
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
// casl/subjects.ts
import { Article } from '../articles/article.entity';
import { Comment } from '../comments/comment.entity';
import { User } from '../users/user.entity';
export type Subjects =
| typeof Article | Article
| typeof Comment | Comment
| typeof User | User
| 'all'; // 모든 주체 (와일드카드)
AbilityFactory: 권한 규칙 정의
사용자의 Role과 속성에 따라 Ability를 생성하는 팩토리입니다:
// casl/casl-ability.factory.ts
import {
AbilityBuilder, createMongoAbility,
MongoAbility, ExtractSubjectType,
} from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { Action } from './actions';
import { Subjects } from './subjects';
export type AppAbility = MongoAbility<[Action, Subjects]>;
@Injectable()
export class CaslAbilityFactory {
createForUser(user: User): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility,
);
switch (user.role) {
case 'admin':
// 관리자: 모든 리소스에 모든 액션
can(Action.Manage, 'all');
// 단, 다른 관리자 삭제는 불가
cannot(Action.Delete, User, { role: 'admin' });
break;
case 'editor':
// 에디터: 글 CRUD 가능
can(Action.Create, Article);
can(Action.Read, Article);
can(Action.Update, Article);
// 자신의 글만 삭제 가능
can(Action.Delete, Article, { authorId: user.id });
// 댓글 관리
can(Action.Manage, Comment);
// 유저 읽기만
can(Action.Read, User);
break;
case 'user':
// 일반 유저: 글 읽기 + 자신의 글 CRUD
can(Action.Read, Article);
can(Action.Create, Article);
can(Action.Update, Article, { authorId: user.id });
can(Action.Delete, Article, { authorId: user.id });
// 자신의 댓글만 관리
can(Action.Create, Comment);
can(Action.Update, Comment, { authorId: user.id });
can(Action.Delete, Comment, { authorId: user.id });
// 자신의 프로필만 수정
can(Action.Read, User);
can(Action.Update, User, { id: user.id });
break;
default:
// 비인증 사용자: 읽기만
can(Action.Read, Article);
can(Action.Read, Comment);
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
}
can(Action.Update, Article, { authorId: user.id })에서 세 번째 인자가 조건입니다. 이 조건은 실제 엔티티의 속성과 비교하여 권한을 판단합니다.
PoliciesGuard: Guard에서 권한 체크
NestJS Guard에서 CASL Ability를 활용하는 패턴입니다:
// casl/policies.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory, AppAbility } from './casl-ability.factory';
// Policy Handler 인터페이스
export interface IPolicyHandler {
handle(ability: AppAbility): boolean;
}
// 함수형 Policy Handler도 지원
type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;
export const CHECK_POLICIES_KEY = 'check_policies';
// 데코레이터
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private caslAbilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const policyHandlers = this.reflector.getAllAndOverride<PolicyHandler[]>(
CHECK_POLICIES_KEY,
[context.getHandler(), context.getClass()],
) ?? [];
const request = context.switchToHttp().getRequest();
const user = request.user;
const ability = this.caslAbilityFactory.createForUser(user);
return policyHandlers.every((handler) =>
typeof handler === 'function'
? handler(ability)
: handler.handle(ability),
);
}
}
컨트롤러에서 사용
@Controller('articles')
@UseGuards(JwtAuthGuard, PoliciesGuard)
export class ArticlesController {
// 글 생성: Article CREATE 권한 필요
@Post()
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Create, Article))
create(@Body() dto: CreateArticleDto, @Req() req) {
return this.articlesService.create(dto, req.user);
}
// 글 목록: Article READ 권한 필요
@Get()
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Read, Article))
findAll(@Req() req) {
return this.articlesService.findAll(req.user);
}
// 글 수정: 해당 글에 대한 UPDATE 권한 필요
@Put(':id')
@CheckPolicies(new UpdateArticlePolicyHandler())
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
return this.articlesService.update(id, dto);
}
// 글 삭제: 해당 글에 대한 DELETE 권한 필요
@Delete(':id')
@CheckPolicies(new DeleteArticlePolicyHandler())
remove(@Param('id') id: string) {
return this.articlesService.remove(id);
}
}
Policy Handler 클래스: 엔티티 기반 권한
Guard 시점에서 엔티티를 조회해야 하는 경우 Policy Handler를 클래스로 구현합니다:
// policies/update-article.policy.ts
export class UpdateArticlePolicyHandler implements IPolicyHandler {
handle(ability: AppAbility): boolean {
// Guard 단계에서는 타입 레벨 체크만
return ability.can(Action.Update, Article);
}
}
// 실제 엔티티 레벨 체크는 서비스에서
@Injectable()
export class ArticlesService {
constructor(
private readonly articleRepo: ArticleRepository,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
async update(id: string, dto: UpdateArticleDto, user: User) {
const article = await this.articleRepo.findOneOrFail(id);
const ability = this.caslAbilityFactory.createForUser(user);
// 실제 엔티티 인스턴스로 권한 체크
if (!ability.can(Action.Update, article)) {
throw new ForbiddenException('이 글을 수정할 권한이 없습니다');
}
return this.articleRepo.save({ ...article, ...dto });
}
}
Guard에서는 타입 레벨 체크(Article 클래스에 대한 Update 가능 여부)를, 서비스에서는 인스턴스 레벨 체크(이 특정 article 인스턴스에 대한 Update 가능 여부)를 수행합니다. 이 Guard 패턴은 NestJS Guard 인가 설계에서 다룬 내용을 확장한 것입니다.
CASL + TypeORM: 쿼리 레벨 필터링
권한 조건을 DB 쿼리에 자동 적용하면 “볼 수 없는 데이터는 아예 조회하지 않는” 패턴을 구현할 수 있습니다:
import { rulesToQuery } from '@casl/ability/extra';
function toTypeOrmCondition(rule): FindOptionsWhere<any> {
// CASL 규칙을 TypeORM where 조건으로 변환
return rule.conditions ?? {};
}
@Injectable()
export class ArticlesService {
async findAll(user: User): Promise<Article[]> {
const ability = this.caslAbilityFactory.createForUser(user);
// 읽기 권한의 조건을 추출
const query = rulesToQuery(ability, Action.Read, Article, toTypeOrmCondition);
if (query === null) {
// 권한 없음
return [];
}
return this.articleRepo.find({
where: query.$or
? query.$or // OR 조건 (여러 규칙)
: {}, // 무조건 허용
});
}
}
CaslModule: 모듈 구성
// casl/casl.module.ts
@Module({
providers: [CaslAbilityFactory, PoliciesGuard],
exports: [CaslAbilityFactory, PoliciesGuard],
})
export class CaslModule {}
// app.module.ts
@Module({
imports: [
AuthModule,
CaslModule,
ArticlesModule,
],
})
export class AppModule {}
동적 권한: DB 기반 규칙
하드코딩 대신 DB에서 권한 규칙을 로드하는 패턴입니다:
// permission.entity.ts
@Entity()
export class Permission {
@PrimaryGeneratedColumn()
id: number;
@Column()
roleId: number;
@Column()
action: string; // 'create', 'read', 'update', 'delete'
@Column()
subject: string; // 'Article', 'Comment', 'User'
@Column({ type: 'jsonb', nullable: true })
conditions: object; // { authorId: '${user.id}' }
@Column({ default: false })
inverted: boolean; // cannot 규칙 여부
}
// casl-ability.factory.ts (DB 기반)
@Injectable()
export class CaslAbilityFactory {
constructor(private permissionRepo: PermissionRepository) {}
async createForUser(user: User): Promise<AppAbility> {
const permissions = await this.permissionRepo.find({
where: { roleId: user.roleId },
});
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility,
);
for (const perm of permissions) {
const conditions = this.interpolate(perm.conditions, user);
const method = perm.inverted ? cannot : can;
method(perm.action as Action, perm.subject, conditions);
}
return build();
}
private interpolate(conditions: any, user: User) {
if (!conditions) return undefined;
const str = JSON.stringify(conditions);
const interpolated = str.replace(/${user.(w+)}/g,
(_, key) => user[key]);
return JSON.parse(interpolated);
}
}
프론트엔드 권한 동기화
CASL은 isomorphic이므로 프론트엔드에서도 같은 규칙을 사용할 수 있습니다:
// API: 사용자의 권한 규칙 반환
@Get('me/permissions')
getPermissions(@Req() req) {
const ability = this.caslAbilityFactory.createForUser(req.user);
return ability.rules; // CASL 규칙 배열 반환
}
// 프론트엔드 (React)
import { createMongoAbility } from '@casl/ability';
const response = await api.get('/me/permissions');
const ability = createMongoAbility(response.data);
// UI에서 조건부 렌더링
if (ability.can('update', article)) {
showEditButton();
}
이렇게 하면 백엔드와 프론트엔드의 권한 로직이 완전히 동기화됩니다. NestJS Custom Decorator와 조합하면 더 선언적인 API를 설계할 수 있습니다.
운영 베스트 프랙티스
- Guard + Service 이중 체크: Guard에서 타입 레벨, Service에서 인스턴스 레벨 권한 체크
- 캐싱: Ability 생성 비용이 크면 요청 스코프 캐시나 Redis에 규칙을 캐싱
- 로깅: 권한 거부 시 누가 무엇을 시도했는지 로깅 (감사 추적)
- 테스트: 각 Role별 권한 규칙을 단위 테스트로 검증
- 최소 권한 원칙: 기본 deny, 필요한 것만 can으로 열기
- 성능: 많은 규칙이 있으면 DB 쿼리 레벨 필터링으로 불필요한 데이터 조회 방지
마무리
CASL은 단순한 RBAC을 넘어 “이 사용자가 이 특정 리소스에 이 액션을 수행할 수 있는가”를 정밀하게 제어합니다. NestJS의 Guard + Decorator 패턴과 결합하면 선언적이고 유지보수하기 쉬운 권한 시스템을 구축할 수 있습니다. DB 기반 동적 권한과 프론트엔드 동기화까지 적용하면 엔터프라이즈급 인가 시스템이 완성됩니다.