Skip to content
Home
Go back

RBAC: Từ lý thuyết đến triển khai production

Một cách triển khai kiểm soát quyền truy cập thường gặp lúc mới bắt đầu là check thẳng theo role:

if (user.role === "admin") {
  deletePost(postId);
}

Cách này hoạt động ổn khi hệ thống chỉ có một vài role cố định. Nhưng khi số lượng role tăng lên (thêm editor được sửa bài nhưng không được xóa, thêm moderator được xóa comment nhưng không được xóa bài, thêm các trường hợp đặc biệt như support được xóa bài của khách hàng report vi phạm nhưng không được đụng bài người khác), các điều kiện if-else dần rải rác khắp codebase. Sửa một chỗ, quên chỗ khác, bug authorization xuất hiện. Đây là lúc cần áp dụng RBAC (Role-Based Access Control) đúng chuẩn thay vì check role rải rác.

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:

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 cần chú ý ở đâ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ì.

Một convention nữa sẽ được 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, phần này trình bày cách á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 quan trọng cần trả lời: user.permissions lấy từ đâu?

Hai hướng, mỗi hướng đánh đổi khác nhau:

Hướng thứ hai với cache Redis TTL 5 phút là lựa chọn hợp lý cho các hệ thống có tính chất nhạy cảm (banking, admin panel…), chấp nhận độ trễ nhỏ để đổi lấy khả năng revoke quyền nhanh.

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:

  1. 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.
  2. 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.
  3. 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.

Ví dụ thực tế: đổi role không có hiệu lực ngay

Một tình huống thường gặp: đổi role của một user từ editor xuống viewer để thu hồi quyền, nhưng user đó vẫn sửa được bài viết bình thường sau đó.

Nguyên nhân: permission được nhúng vào JWT lúc login, với TTL token 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, nên user vẫn giữ được permission cũ trong suốt thời gian còn lại của token.

Bài học: nếu chọn nhúng permission vào JWT, cần có cơ chế revoke token chủ động (blacklist, hoặc short-lived token kết hợp refresh token kiểm tra lại permission mỗi lần refresh). Chỉ đổi dữ liệu trong DB là chưa đủ.

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:

Biết trước ranh giới của RBAC giúp tránh việc cố nhét mọi rule vào role/permission một cách gượng ép khi chưa thực sự cần thiết.

Tổng kết

Checklist ngắn cho ai chuẩn bị làm RBAC từ đầu:

RBAC nghe đơn giản trên giấy, nhưng 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.


Share this post:

Previous Post
Xây dựng Button Component theo pattern shadcn/ui
Next Post
ABAC: Attribute-Based Access Control khi RBAC không còn đủ