Skip to content
Home
Go back

ABAC: Khi RBAC không còn đủ, và bài học đầu tiên với policy engine

bài trước, mình kể chuyện làm RBAC từ một if-else một dòng cho tới lúc có schema đàng hoàng, có Guard, có Service check ownership. Mọi thứ chạy ổn một thời gian dài, cho tới khi có một yêu cầu mới: “user chỉ được sửa đơn hàng trong vòng 15 phút sau khi tạo, sau đó khóa lại, kể cả chính người tạo đơn cũng không sửa được nữa”.

Mình thử nhét vào hệ thống permission cũ. order:edit:own thì đã có sẵn, đơn giản là check order.createdBy === userId. Nhưng “trong vòng 15 phút” là điều kiện phụ thuộc thời gian hiện tại, không phải một thuộc tính cố định nào của user hay role. Không có cách nào biểu diễn nó bằng permission tĩnh cả, vì permission chỉ trả lời được câu “có được làm hay không”, chứ không trả lời được “được làm khi nào”.

Đó là lúc mình phải học ABAC.

Mục lục

Xem mục lục

ABAC là gì, khác RBAC ở chỗ nào

RBAC quyết định dựa trên role: user thuộc role nào, role đó có permission nào. ABAC (Attribute-Based Access Control) quyết định dựa trên attribute, tức thuộc tính của mọi thứ liên quan đến request:

Một policy ABAC nhìn chung có dạng:

allow if <điều kiện trên attribute của subject, resource, action, environment>

Áp vào ví dụ đơn hàng ở trên:

allow edit order if
  subject.id == resource.ownerId
  AND (environment.now - resource.createdAt) < 15 phút

Không còn khái niệm “permission cố định gán cho role” nữa, mà là một biểu thức điều kiện được đánh giá tại thời điểm request đến. Đây chính là khác biệt cốt lõi: RBAC trả lời câu hỏi tĩnh, ABAC trả lời câu hỏi động, phụ thuộc dữ liệu và ngữ cảnh lúc request diễn ra.

Nhầm lẫn đầu tiên: ABAC và PBAC là một à

Lúc mới đọc, mình từng nghĩ ABAC và PBAC (Policy-Based Access Control) là hai cái tên khác nhau cho cùng một thứ, vì tài liệu nào cũng nhắc chung với nhau. Không hẳn.

PBAC là kiến trúc: tách toàn bộ logic phân quyền ra thành policy riêng, đánh giá tập trung bởi một nơi duy nhất, thay vì rải rác khắp code bằng if-else tùy tiện. ABAC là nội dung của policy đó, cụ thể là dựa trên attribute. Nói cách khác, ABAC là một dạng cụ thể của PBAC, chứ không phải hai khái niệm tách biệt. Thư viện như CASL (định nghĩa ability ngay trong code) hay các policy engine dùng file config tách rời như OPA đều triển khai theo hướng PBAC, chỉ khác nhau ở chỗ đặt “nơi tập trung” đó ở đâu, còn nội dung bên trong thuần role, thuần attribute, hay lai cả hai là tùy bạn.

Hiểu nhầm này khiến mình từng nghĩ phải chọn “ABAC hoặc RBAC”, trong khi thực tế có thể kết hợp cả hai trong cùng một policy, như phần tiếp theo sẽ nói.

Role không biến mất, nó trở thành một attribute

Điều khiến mình yên tâm nhất khi bắt đầu là: không cần vứt bỏ RBAC cũ. Role hoàn toàn có thể tồn tại như một attribute trong policy ABAC:

allow edit order if
  subject.role == 'admin'
  OR (subject.id == resource.ownerId AND (environment.now - resource.createdAt) < 15 phút)

Role vẫn đóng đúng vai trò như trước, chỉ là được đặt cạnh các attribute khác thay vì đứng một mình. Vì vậy migrate từ RBAC sang ABAC không phải là rewrite toàn bộ hệ thống phân quyền, mà là mở rộng dần những chỗ cần rule động, còn phần còn lại vẫn dùng role như cũ.

Thử triển khai với CASL trong NestJS

Mình chọn CASL, vì đúng NestJS docs cũng khuyến nghị dùng cho bài toán authorization, và rule được định nghĩa ngay trong code TypeScript, có type check, thay vì một DSL rời rạc nằm ngoài codebase.

Định nghĩa ability, tương đương khái niệm “policy” trong ABAC nói chung nhưng viết bằng code thuần:

// casl-ability.factory.ts
import { AbilityBuilder, PureAbility, AbilityClass } from '@casl/ability';

type Actions = 'manage' | 'edit' | 'delete' | 'view';
type Subjects = 'Order' | 'all';
export type AppAbility = PureAbility<[Actions, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: User) {
    const { can, build } = new AbilityBuilder<AppAbility>(
      PureAbility as AbilityClass<AppAbility>,
    );

    if (user.role === 'admin') {
      can('manage', 'all');
    } else {
      can('edit', 'Order', {
        ownerId: user.id,
        createdAt: { $gt: new Date(Date.now() - 15 * 60 * 1000) },
      });
    }

    return build();
  }
}

Decorator và Guard, cùng cấu trúc với PermissionsGuard ở bài trước, chỉ khác chỗ đánh giá quyền:

// check-abilities.decorator.ts
export const CHECK_ABILITY = 'check_ability';
export interface RequiredRule {
  action: Actions;
  subject: Subjects;
}
export const CheckAbilities = (...rules: RequiredRule[]) =>
  SetMetadata(CHECK_ABILITY, rules);
// abilities.guard.ts
import { subject } from '@casl/ability';

@Injectable()
export class AbilitiesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private caslAbilityFactory: CaslAbilityFactory,
    private ordersService: OrdersService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const rules =
      this.reflector.get<RequiredRule[]>(CHECK_ABILITY, context.getHandler()) ?? [];
    const request = context.switchToHttp().getRequest();
    const { user } = request;
    const order = await this.ordersService.findOne(request.params.id);
    const ability = this.caslAbilityFactory.createForUser(user);

    return rules.every(rule => ability.can(rule.action, subject(rule.subject, order)));
  }
}
@UseGuards(JwtAuthGuard, AbilitiesGuard)
@CheckAbilities({ action: 'edit', subject: 'Order' })
@Patch(':id')
updateOrder(@Param('id') id: string, @Body() dto: UpdateOrderDto) { ... }

Khác biệt lớn nhất so với PermissionsGuard cũ vẫn giữ nguyên như hướng ABAC nói chung: Guard cần load resource thật (order) trước khi tạo ability và check, vì điều kiện ownerId, createdAt chỉ có ý nghĩa khi so với đúng bản ghi đang bị tác động. PermissionsGuard cũ chỉ cần đọc metadata tĩnh trên route, không cần chạm database.

Có một chi tiết dễ bỏ sót: CASL cần biết order thuộc “subject” nào để so khớp với điều kiện đã khai báo (can('edit', 'Order', ...)), nên phải bọc qua helper subject('Order', order) thay vì truyền thẳng object. Bỏ qua bước này là nguồn gốc của bug mình kể ở phần sau.

Đường đi migrate dần từ RBAC

Cách mình làm, tránh big bang rewrite:

  1. Giữ nguyên schema roles/permissions, không đụng vào những route đang chạy ổn.
  2. Đưa CASL vào song song, chỉ áp dụng cho các route thực sự cần rule động, như route sửa đơn hàng ở trên.
  3. Viết ability bằng cách nhúng role vào, như ví dụ if (user.role === 'admin') can('manage', 'all'), để tận dụng lại toàn bộ dữ liệu role đã có, không cần thiết kế attribute mới từ đầu.
  4. Mở rộng dần theo từng feature, route nào có rule tĩnh thuần túy thì cứ để PermissionsGuard cũ lo, route nào cần điều kiện động mới chuyển qua AbilitiesGuard.

Sau vài tháng, hệ thống của mình có cả hai Guard tồn tại song song, mỗi route dùng loại phù hợp, không có route nào bị ép dùng ABAC nếu RBAC vẫn đủ.

Cái giá phải trả

Không có gì miễn phí, và ABAC thì “đắt” hơn RBAC ở vài điểm rõ rệt:

Một buổi tối mất hai tiếng vì quên gắn subject type

Lần đầu tích hợp, AbilitiesGuard từ chối mọi request, kể cả những case đáng lẽ phải allow, dù ability khai báo y hệt ví dụ trong docs CASL.

Hóa ra CASL xác định “subject” của một object bằng object.constructor.name theo mặc định. order mình truyền vào lúc check ability là kết quả findOne() từ TypeORM, một plain object thuần, constructor.name của nó ra 'Object', không phải 'Order' như đã khai báo trong can('edit', 'Order', ...). So sánh subject type bằng string thất bại âm thầm, không throw lỗi gì cả, ability chỉ đơn giản trả về false. Mình mất gần hai tiếng nghi ngờ đủ thứ, từ dữ liệu createdAt sai timezone cho tới điều kiện $gt viết sai, cuối cùng mới lần ra đúng chỗ.

Cách sửa là dùng helper subject() để gắn type tường minh, như đoạn code Guard ở trên đã có:

ability.can('edit', subject('Order', order));

Bài học rút ra: dùng thư viện type-safe hơn giúp tránh được lỗi cú pháp DSL rời rạc, nhưng không loại bỏ hoàn toàn kiểu lỗi “âm thầm từ chối, không báo gì”. Vẫn cần một bộ test case cố định cho từng ability quan trọng, không nên chỉ tin vào đọc code bằng mắt.

Bài học: viết policy bằng string dễ sai và khó phát hiện hơn code thông thường rất nhiều. Nên có bộ test case cố định cho từng policy quan trọng, chạy enforce thử với input giả lập, thay vì chỉ tin tưởng đọc lại file bằng mắt.

Khi nào thực sự cần ABAC, khi nào RBAC vẫn đủ

Sau khi dùng cả hai, đây là cách mình tự hỏi trước khi quyết định:

Ngoài RBAC và ABAC, còn một hướng nữa đang phổ biến nhanh trong các SaaS có tính năng chia sẻ phức tạp kiểu Notion, Google Drive, đó là ReBAC (Relationship-Based Access Control), dựa trên quan hệ giữa các entity thay vì role hay attribute. Cái này để dành cho một bài khác.

Tổng kết

RBAC dạy mình cách tổ chức quyền cho gọn. ABAC dạy mình rằng có những rule không thể gọn được, và đôi khi việc đúng đắn không phải là cố nhét cho bằng được, mà là biết khi nào cần đổi công cụ.


Share this post:

Previous Post
RBAC: Từ lý thuyết sách vở đến lúc thực sự chạy production
Next Post
Thu hồi JWT: Cái giá của việc chọn stateless