NestJS CASL 권한 관리 심화

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 기반 동적 권한과 프론트엔드 동기화까지 적용하면 엔터프라이즈급 인가 시스템이 완성됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux