Skip to content
Home
Go back

ABAC: Attribute-Based Access Control khi RBAC không còn đủ

Ở bài trước, RBAC được triển khai 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. Cách tiếp cận đó xử lý tốt các rule tĩnh, nhưng gặp giới hạn khi có yêu cầu như: “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”.

Permission tĩnh kiểu order:edit:own chỉ trả lời được câu hỏi “có được làm hay không” bằng cách 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 của user hay role. Permission tĩnh không biểu diễn được điều kiện này, đây là lúc cần tới 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.

Phân biệt ABAC và PBAC

ABAC và PBAC (Policy-Based Access Control) thường bị nhầm là hai tên gọi khác nhau cho cùng một khái niệm, vì tài liệu thường nhắc chung với nhau. Thực tế không hẳn vậy.

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 thiết kế.

Từ đây có thể thấy: không cần chọn “ABAC hoặc RBAC”, vì thực tế có thể kết hợp cả hai trong cùng một policy, như phần tiếp theo trình bày.

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

Điều quan trọng cần lưu ý: 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

CASL là lựa chọn phù hợp cho bài toán này, vì đúng NestJS docs cũng khuyến nghị dùng cho 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 lỗi được trình bày ở phần sau.

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

Cách tiếp cận tránh big bang rewrite:

  1. Giữ nguyên schema roles/permissions cũ, 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 một thời gian áp dụng, hệ thống 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:

Ví dụ thực tế: lỗi do quên gắn subject type

Một lỗi thường gặp khi mới tích hợp CASL: AbilitiesGuard từ chối mọi request, kể cả những case đáng lẽ phải allow, dù ability khai báo đúng như ví dụ trong docs CASL.

Nguyên nhân: CASL xác định “subject” của một object bằng object.constructor.name theo mặc định. Đối tượng order 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. Lỗi kiểu này khó phát hiện vì không có exception hay log nào chỉ ra nguyên nhân, dễ nhầm sang các hướng khác như dữ liệu createdAt sai timezone hay điều kiện $gt viết sai.

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: viết policy dựa trên object thuần và so khớp type bằng string dễ gây lỗi âm thầm, không báo gì, khó phát hiện qua đọc code bằng mắt. Nên có bộ test case cố định cho từng ability quan trọng, chạy enforce thử với input giả lập thay vì chỉ tin vào review thủ công.

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

Trước khi quyết định dùng ABAC hay giữ RBAC, có thể tự hỏi:

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 phù hợp để tổ chức quyền theo rule tĩnh, gọn và dễ maintain. ABAC xử lý được các rule không thể gọn vào role/permission cố định, nhưng đánh đổi bằng chi phí phức tạp hơn. Lựa chọn đúng không phải là cố nhét mọi rule vào một mô hình, mà là biết khi nào cần đổi công cụ.


Share this post:

Previous Post
RBAC: Từ lý thuyết đến triển khai production
Next Post
Thu hồi JWT: Cái giá của việc chọn stateless