Phân quyền Isomorphic với CASL: Tuyệt chiêu đồng bộ logic NestJS và React

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Cái bẫy “nhân đôi” logic khi làm phân quyền

Bạn đã bao giờ rơi vào cảnh này chưa? Bạn viết một đống logic if (user.role === 'admin') ở NestJS để bảo vệ API. Ngay sau đó, bạn lại phải copy y hệt đoạn đó sang React để ẩn hiện nút bấm. Mọi thứ dần trở thành ác mộng khi yêu cầu thay đổi. Giả sử sếp muốn ‘Editor’ cũng được xóa bài, bạn sẽ phải lục tung cả hai đầu project để sửa. Chỉ cần sót một chỗ, hệ thống sẽ xuất hiện lỗi logic hoặc nghiêm trọng hơn là lỗ hổng bảo mật.

Đó là lúc Isomorphic Authorization (phân quyền đồng nhất) cứu cánh cho bạn. Thay vì duy trì logic ở hai nơi, chúng ta chỉ định nghĩa một bộ quy tắc duy nhất. CASL chính là thư viện mạnh mẽ nhất để hiện thực hóa điều này.

Tại sao CASL lại thay đổi cuộc chơi?

CASL (phát âm là /’kæsəl/) quản lý phân quyền dựa trên khả năng (Ability-based). Thay vì kiểm tra Role cứng nhắc kiểu isAdmin, CASL tập trung vào việc: User được phép làm hành động gì trên đối tượng nào.

Lợi thế lớn nhất của CASL là tính đồng nhất. Bạn định nghĩa logic trong một file TypeScript dùng chung, sau đó import vào NestJS để chặn API và đưa vào React để render UI. Khi logic thay đổi, bạn chỉ cần chỉnh sửa đúng một file duy nhất.

4 khái niệm bạn cần nắm vững

  • Actions: Hành động như manage (toàn quyền), create, read, update, delete.
  • Subject: Đối tượng chịu tác động như User, Post, hoặc 'all'.
  • Ability: Tổng hợp các quy tắc cho phép user thao tác.
  • Conditions: Điều kiện sâu hơn. Ví dụ: User chỉ được sửa bài viết nếu authorId trùng với userId của họ.

Thực hành: Triển khai CASL cho Fullstack Project

1. Thiết lập Logic dùng chung (Shared Logic)

Hãy tạo file ability.ts trong folder shared. Đây sẽ là “nguồn sự thật duy nhất” cho cả Backend và Frontend.

// shared/ability.ts
import { AbilityBuilder, PureAbility, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability';

export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

export type Subjects = InferSubjects<'Post' | 'User'> | 'all';
export type AppAbility = PureAbility<[Action, Subjects]>;
export const AppAbility = PureAbility as AbilityClass<AppAbility>;

export function defineAbilityFor(user: any) {
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(AppAbility);

  if (user.role === 'admin') {
    can(Action.Manage, 'all'); 
  } else {
    can(Action.Read, 'all');
    // Rule thực tế: Chỉ cho phép sửa bài của chính mình
    can(Action.Update, 'Post', { authorId: user.id }); 
    cannot(Action.Delete, 'Post').because('Chỉ quản trị viên mới có quyền xóa bài');
  }

  return build({
    detectSubjectType: (item) => item.constructor.name as ExtractSubjectType<Subjects>,
  });
}

2. Tích hợp vào NestJS Backend

Ở Backend, chúng ta cần một Factory để khởi tạo quyền dựa trên User từ Request. Khi xử lý các Object phân quyền phức tạp chứa nhiều metadata, mình thường dùng toolcraft.app để format và kiểm tra cấu trúc JSON nhanh hơn, tránh nhầm lẫn logic.

npm install @casl/ability

Tạo CaslAbilityFactory để sử dụng trong các Guard:

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: any) {
    return defineAbilityFor(user);
  }
}

Sau đó, bạn chỉ cần viết một PermissionsGuard để kiểm tra quyền trước khi Request vào Controller. Cách này giúp chặn đứng các truy cập trái phép ngay từ lớp bảo vệ đầu tiên.

3. Đồng bộ lên React Frontend

Cài đặt package hỗ trợ React:

npm install @casl/react @casl/ability

Sử dụng AbilityContext để bao bọc ứng dụng. Bạn sẽ tái sử dụng chính hàm defineAbilityFor từ bước 1. Lúc này, việc ẩn hiện UI trở nên cực kỳ nhàn:

import { Can } from './context/AbilityContext';

function PostItem({ post }) {
  return (
    <div>
      <h2>{post.title}</h2>
      {/* Nút Edit chỉ hiện nếu user có quyền update đúng bài post này */}
      <Can I="update" this={post}>
        <button>Chỉnh sửa</button>
      </Can>

      <Can I="delete" a="Post">
        <button>Xóa bài</button>
      </Can>
    </div>
  );
}

Mẹo thực chiến: Đừng để mất dấu Class

Một lỗi phổ biến là khi Backend trả về JSON thô, CASL ở Frontend sẽ không biết đó là Object thuộc Class nào. Hệ quả là các quy tắc như a('Post') sẽ chạy sai.

Giải pháp là hãy dùng helper function để “hydrate” object. Bạn có thể gán thêm thuộc tính __type hoặc dùng class-transformer để chuyển đổi dữ liệu thô thành instance của Class. Khi đó, CASL sẽ mapping chính xác các quy tắc can/cannot mà bạn đã định nghĩa.

Tổng kết

Áp dụng CASL giúp code của bạn tuân thủ nguyên lý DRY (Don’t Repeat Yourself). Tuy tốn chút thời gian setup ban đầu, nhưng bạn sẽ tiết kiệm hàng giờ debug về sau. Đặc biệt là khi dự án mở rộng lên hàng chục Role và hàng trăm quyền hạn khác nhau. Đừng quên dùng toolcraft.app để hỗ trợ soi dữ liệu phân quyền khi cần nhé. Chúc anh em triển khai thành công!

Share: