Ở 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
- Nhầm lẫn đầu tiên: ABAC và PBAC là một à
- Role không biến mất, nó trở thành một attribute
- Thử triển khai với CASL trong NestJS
- Đường đi migrate dần từ RBAC
- Cái giá phải trả
- Một buổi tối mất hai tiếng vì quên gắn subject type
- Khi nào thực sự cần ABAC, khi nào RBAC vẫn đủ
- Tổng kết
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:
- Subject (user): role, department, level…
- Resource (đối tượng bị tác động): owner, trạng thái, thời điểm tạo…
- Action: hành động đang thực hiện (edit, delete, view…)
- Environment: thời gian hiện tại, IP, thiết bị…
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:
- Giữ nguyên schema
roles/permissionscũ, không đụng vào những route đang chạy ổn. - Đư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.
- 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. - Mở rộng dần theo từng feature, route nào có rule tĩnh thuần túy thì cứ để
PermissionsGuardcũ lo, route nào cần điều kiện động mới chuyển quaAbilitiesGuard.
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:
- Performance: mỗi lần check ability cần load resource thật từ DB trước, thêm ít nhất một round-trip so với
PermissionsGuardcũ chỉ đọc metadata. Route nào gọi nhiều, nên cache resource hoặc cache luôn kết quả check trong thời gian ngắn. - Vẫn có kiểu lỗi âm thầm: điều kiện trong CASL là object thuần (
{ ownerId: user.id, createdAt: { $gt: ... } }), field name không được type check với entity thật trừ khi tự thêm generic chặt chẽ hơn. Gõ nhầm tên field, hoặc quên gắn subject type qua helpersubject(), đều không throw lỗi gì cả, chỉ đơn giản là ability luôn trả vềfalse. - Test khó hơn: unit test một Guard cũ chỉ cần mock
user.permissions. TestAbilitiesGuardcần mock cả resource, cả thời gian hệ thống, độ phức tạp test case tăng theo số lượng điều kiện tham gia vào ability.
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:
- Rule có phụ thuộc thời gian, trạng thái resource, hay dữ liệu runtime khác không? Có thì cân nhắc ABAC, không thì RBAC vẫn đủ.
- Rule đó có xuất hiện ở nhiều nơi, nhiều feature không, hay chỉ một route duy nhất? Chỉ một route thì đôi khi viết thẳng điều kiện trong Service, như bài RBAC trước đã làm với ownership, còn đơn giản và nhanh hơn kéo cả policy engine vào.
- Team có sẵn sàng maintain thêm một tầng phức tạp không, cả về performance lẫn debug? Nếu team nhỏ, ít kinh nghiệm với policy engine, nên cân nhắc kỹ trước khi thêm.
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
- ABAC quyết định dựa trên attribute của subject, resource, action, environment, thay vì role cố định như RBAC.
- ABAC là một dạng cụ thể của PBAC, không phải hai khái niệm tách biệt.
- Role không mất đi khi chuyển sang ABAC, nó trở thành một attribute trong policy.
- Migrate nên làm dần theo từng feature cần rule động, giữ nguyên RBAC cho phần còn lại, tránh rewrite toàn bộ.
- Đổi lại là chi phí performance, debug, và test khó hơn, cần cân nhắc kỹ trước khi thêm ABAC vào một route.
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ụ.