Hồi mới đi làm, mình từng nhận một task tưởng chừng đơn giản: “user thường không được xóa bài viết, chỉ admin mới được xóa”. Mình gõ luôn một dòng:
if (user.role === "admin") {
deletePost(postId);
}
Xong, ship, tự thấy mình xử lý gọn gàng.
Ba tháng sau, sếp yêu cầu thêm role editor, được sửa bài nhưng không được xóa. Rồi thêm moderator, được xóa comment nhưng không được xóa bài. Rồi có ngày, một bạn support cần quyền xóa bài của chính khách hàng report vi phạm, nhưng không được đụng vào bài của người khác.
Cái if-else một dòng ngày nào giờ thành một mớ role === 'admin' || role === 'moderator' || (role === 'editor' && ...) rải khắp codebase. Sửa một chỗ, quên chỗ khác, bug authorization xuất hiện. Đó là lúc mình mới thực sự ngồi học lại RBAC cho đàng hoàng.
Mục lục
Xem mục lục
RBAC là gì, nói ngắn gọn
RBAC (Role-Based Access Control) chỉ có 3 khái niệm cốt lõi:
- User: người dùng.
- Role: một “vai trò” (admin, editor, viewer…).
- Permission: một hành động cụ thể được phép làm (
post:delete,post:edit…).
Quan hệ giữa chúng:
User ---- có nhiều ----> Role ---- có nhiều ----> Permission
Role không tự nó có ý nghĩa gì cả, nó chỉ là một cái túi đựng permission. Điều quan trọng nhất, dễ hiểu sai nhất, chính là điều tiếp theo.
Cái bẫy đầu tiên: hard-code role trong code
Đoạn code mở đầu bài này chính là cái bẫy:
// "Simple RBAC", trông giống RBAC nhưng không phải
if (user.role === 'admin') { ... }
Cách này sai ở chỗ: permission bị hard-code trực tiếp vào logic, gắn chết với tên role. Muốn thêm role mới có quyền tương tự admin? Phải sửa code ở mọi nơi có check role === 'admin'. Với codebase vài chục chỗ check như vậy, khả năng sót là gần như chắc chắn.
RBAC đúng chuẩn phải check theo permission, không phải theo role:
// RBAC đúng nghĩa
if (user.hasPermission('post:delete')) { ... }
Code không cần biết user có role gì. Role chỉ là chi tiết implementation ở tầng data: admin có role chứa permission post:delete, moderator cũng có thể có permission đó, code không quan tâm. Thêm role mới, gán permission cho nó, không đụng một dòng code nào.
Database schema
Từ nguyên lý trên, schema tối thiểu cho RBAC cần 4 bảng, ngoài bảng users có sẵn:
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, -- 'admin', 'editor', 'viewer'
description TEXT
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL, -- 'post:create', 'post:delete:own'
description TEXT
);
-- role <-> permission: n-n
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- user <-> role: n-n (1 user có thể có nhiều role)
CREATE TABLE user_roles (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Điểm mình muốn nhấn ở đây là convention đặt tên permission: resource:action, ví dụ post:delete, user:manage. Đặt tên mơ hồ kiểu canDelete sẽ rất nhanh mất kiểm soát khi permission tăng lên vài chục cái, vì không biết canDelete là xóa cái gì.
Còn một convention nữa mình sẽ nói rõ hơn ở phần sau: tách post:delete:own và post:delete:any để phân biệt “xóa bài của chính mình” và “xóa bài bất kỳ”.
Triển khai thực tế với NestJS
Có schema rồi, đây là cách mình áp dụng vào NestJS.
Decorator khai báo permission cần thiết cho từng route:
// permissions.decorator.ts
export const PERMISSIONS_KEY = "permissions";
export const Permissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
Guard đọc metadata và check:
// permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
return required.every(p => user.permissions?.includes(p));
}
}
Áp dụng vào controller:
@UseGuards(JwtAuthGuard, PermissionsGuard)
@Permissions('post:delete:any')
@Delete(':id')
deletePost(@Param('id') id: string) { ... }
Câu hỏi mình từng loay hoay nhất: user.permissions lấy từ đâu?
Hai hướng, mỗi hướng đánh đổi khác nhau:
- Nhét vào JWT payload lúc login: nhanh, không tốn round-trip DB mỗi request. Nhưng đổi permission cho user (thu hồi role chẳng hạn) sẽ không có hiệu lực ngay, phải chờ token hết hạn hoặc user re-login/refresh token.
- Query DB hoặc cache Redis mỗi request: real-time, thu hồi quyền có hiệu lực ngay lập tức. Đánh đổi là thêm 1 round-trip mỗi request, nên gần như bắt buộc phải cache (TTL ngắn, vài phút) để không kéo tụt performance.
Mình chọn hướng thứ hai với cache Redis TTL 5 phút, chấp nhận độ trễ nhỏ để đổi lấy khả năng revoke quyền nhanh, thường quan trọng hơn trong các hệ thống có tính chất nhạy cảm (banking, admin panel…).
Vấn đề hay gặp
Multi-role cho 1 user
Thực tế gần như luôn cần: 1 user có thể vừa là editor vừa là support. Permission cuối cùng là hợp (union) permission từ tất cả role, không phải giao:
SELECT DISTINCT p.name
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1;
Chú ý DISTINCT, vì 2 role có thể trùng permission (editor và moderator đều có post:edit), không dedupe thì dữ liệu trả về bị rác.
Một giới hạn quan trọng cần biết: RBAC thuần không có khái niệm “deny”, chỉ có allow. Nếu role A cho phép mà role B “muốn cấm” thì RBAC không xử lý được tình huống này, chỉ có thể union permission một cách đơn giản (không loại trừ được), hoặc nhảy sang ABAC/policy engine (nói ở phần cuối).
Permission check nên đặt ở đâu
Ba lớp, mỗi lớp giải quyết vấn đề khác nhau, không lớp nào thay được lớp kia:
- UI (frontend): ẩn nút “Xóa” nếu không có quyền. Đây không phải bảo mật, chỉ là UX. Ai mở DevTools hoặc gọi thẳng API bằng Postman là bypass hoàn toàn.
- Guard/Middleware: chặn request trước khi vào handler, dựa trên permission tĩnh (
@Permissions('post:delete:any')). Là lớp bảo mật thật đầu tiên, nhưng có giới hạn: guard không biết resource cụ thể nào đang bị động tới. - Service layer: nơi check rule phụ thuộc dữ liệu thực tế, như ownership:
async deletePost(userId: number, postId: number, permissions: string[]) {
const post = await this.postsRepo.findOne(postId);
const canDeleteAny = permissions.includes('post:delete:any');
const canDeleteOwn = permissions.includes('post:delete:own') && post.authorId === userId;
if (!canDeleteAny && !canDeleteOwn) {
throw new ForbiddenException();
}
...
}
Đây chính là lý do phải tách post:delete:own / post:delete:any thay vì gộp chung: guard không phân biệt được ownership, buộc phải đẩy check này xuống service.
Vì sao check 1 chỗ là chưa đủ: chỉ check ở Guard sẽ lọt mất ownership. User có đúng permission post:delete nhưng lại xóa được post của người khác. Đây chính là lỗ hổng BOLA/IDOR (Broken Object Level Authorization), nằm trong OWASP API Security Top 10. Nguy hiểm hơn nữa: nếu hệ thống có nhiều entry point cùng gọi vào chung service (REST, GraphQL, cron job, message queue consumer) mà permission chỉ được check ở HTTP Guard, thì các entry point khác gọi thẳng service sẽ hoàn toàn không có authorization nào.
Một buổi tối không vui
Có lần mình đổi role của một user từ editor xuống viewer, thu hồi bớt quyền vì bạn đó chuyển team. Mọi thứ ổn cho tới khi bạn ấy báo vẫn sửa được bài viết bình thường.
Hóa ra permission của bạn đó được nhét vào JWT lúc login, TTL token là 7 ngày. Đổi role trong DB không có tác dụng gì cho tới khi token cũ hết hạn. Users vẫn cầm “quyền lực” cũ trong túi suốt gần một tuần.
Bài học: nếu chọn nhét permission vào JWT, phải có cơ chế revoke token chủ động (blacklist, hoặc short-lived token + refresh token check lại permission mỗi lần refresh). Không thể chỉ đổi DB rồi coi như xong.
Khi nào RBAC không đủ
RBAC xử lý tốt các rule tĩnh, kiểu “role này được làm gì”. Nhưng khi rule phụ thuộc vào ngữ cảnh, như giờ trong ngày, trạng thái resource, hay mối quan hệ giữa user và resource phức tạp hơn ownership đơn giản, thì RBAC bắt đầu đuối.
Lúc đó là lúc nhìn sang:
- ABAC (Attribute-Based Access Control): quyết định dựa trên thuộc tính (attribute) của user, resource, môi trường, không chỉ role.
- Policy engine như Casbin hay OPA: viết rule dưới dạng policy, tách hẳn khỏi code.
- CASL (JS/TS): hợp với các rule kiểu “chỉ sửa được nếu vẫn còn trong khung giờ cho phép sửa”.
Mình chưa cần tới mức đó, nhưng biết trước ranh giới của RBAC để không cố nhét mọi rule vào role/permission một cách gượng ép.
Tổng kết
Checklist ngắn cho ai chuẩn bị làm RBAC từ đầu:
- Check theo permission, không check theo role trong code.
- Đặt tên permission theo convention
resource:action, cân nhắc tách:own/:anynếu có ownership. - Permission luôn là union của tất cả role user đang có, nhớ
DISTINCT. - Chọn rõ: JWT (nhanh, revoke chậm) hay query + cache (chậm hơn chút, revoke nhanh). Nếu chọn JWT, phải có cơ chế revoke chủ động.
- Check permission ở nhiều lớp: Guard cho permission tĩnh, Service cho ownership/business rule. Đừng tin một lớp duy nhất.
- Khi rule bắt đầu phụ thuộc ngữ cảnh phức tạp, đừng cố ép vào RBAC, mà nhìn sang ABAC/policy engine.
RBAC nghe đơn giản trên giấy, nhưng như nhiều thứ khác trong nghề này, cái khó nằm ở lúc hệ thống lớn dần theo thời gian chứ không phải ở lúc mới viết dòng code đầu tiên.