- Đặt vấn đề: Thách thức phân quyền trong các hệ thống Dashboard doanh nghiệp lớn
- Kiến trúc Dynamic RBAC và Fine-Grained Access Control (FGAC)
- Triển khai Middleware phân quyền động trong ExpressJS
- Nâng cao: Giải quyết bài toán Quyền sở hữu tài nguyên (Resource Ownership)
- Đồng bộ và Kiểm soát hiển thị UI động trong React với Zustand
- Tối ưu hóa hiệu năng và bảo mật cho hệ thống phân quyền
- Kết luận
Đặt vấn đề: Thách thức phân quyền trong các hệ thống Dashboard doanh nghiệp lớn
Khi xây dựng các hệ thống quản trị quy mô lớn, đặc biệt là các hệ thống Ecommerce Dashboard đa bên (Multi-vendor), việc quản lý quyền hạn của người dùng luôn là một trong những bài toán hóc búa nhất. Ban đầu, hệ thống chỉ có hai vai trò cơ bản là Admin và User. Tuy nhiên, khi doanh nghiệp phát triển, các yêu cầu phân quyền phức tạp hơn bắt đầu xuất hiện: Nhân viên kho chỉ được xem và cập nhật trạng thái đơn hàng nhưng không được sửa giá sản phẩm; Nhân viên Marketing chỉ được tạo mã giảm giá; Quản lý chi nhánh chỉ được xem báo cáo doanh thu của chi nhánh mình phụ trách.
Nếu chúng ta sử dụng phương pháp kiểm tra vai trò cứng (Hardcoded Role-Based Access Control) bằng cách viết các câu lệnh kiểm tra trực tiếp như if (user.role === 'admin') rải rác khắp mã nguồn, hệ thống sẽ nhanh chóng trở thành một cơn ác mộng bảo trì. Khi doanh nghiệp muốn thêm một vai trò mới hoặc thay đổi quyền hạn của một vai trò hiện tại, lập trình viên sẽ phải tìm kiếm và sửa đổi mã nguồn ở hàng trăm nơi, dẫn đến nguy cơ cao xảy ra lỗi bảo mật nghiêm trọng.
Để giải quyết triệt để vấn đề này, chúng ta cần thiết kế một hệ thống Phân quyền Động (Dynamic RBAC) kết hợp với Kiểm soát Truy cập Chi tiết (Fine-Grained Access Control - FGAC). Hệ thống này cho phép định nghĩa các quyền hạn (Permissions) độc lập với vai trò (Roles), lưu trữ cấu hình phân quyền trong cơ sở dữ liệu và kiểm tra quyền hạn một cách linh hoạt ở cả Backend lẫn Frontend.
Kiến trúc Dynamic RBAC và Fine-Grained Access Control (FGAC)
Trong mô hình RBAC truyền thống, người dùng được gán trực tiếp vào các vai trò (Roles), và các hành động được quyết định dựa trên vai trò đó. Tuy nhiên, mô hình này thiếu tính linh hoạt. Với Dynamic RBAC kết hợp FGAC, chúng ta chia nhỏ quyền hạn thành hai thực thể chính:
- Tài nguyên (Resource): Đối tượng mà người dùng muốn tác động (ví dụ:
products,orders,users,settings). - Hành động (Action): Thao tác mà người dùng muốn thực hiện trên tài nguyên đó (ví dụ:
create,read,update,delete).
Một quyền hạn (Permission) sẽ là sự kết hợp giữa một Hành động và một Tài nguyên (ví dụ: create:products, update:orders). Các vai trò (Roles) sẽ là một tập hợp các quyền hạn này. Người dùng (Users) sẽ được gán vào một hoặc nhiều vai trò. Khi người dùng thực hiện một yêu cầu, hệ thống sẽ kiểm tra xem vai trò của người dùng đó có chứa quyền hạn cụ thể cần thiết để thực hiện hành động trên tài nguyên đó hay không.
Mô hình hóa dữ liệu với Prisma (MySQL)
Để triển khai kiến trúc này, chúng ta cần thiết kế một cấu trúc cơ sở dữ liệu quan hệ chặt chẽ. Dưới đây là schema Prisma mô tả mối quan hệ nhiều-nhiều giữa Roles và Permissions, cùng mối quan hệ một-nhiều giữa Users và Roles:
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
email String @unique
password String
roleId String
role Role @relation(fields: [roleId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Role {
id String @id @default(uuid())
name String @unique
description String?
users User[]
permissions Permission[] @relation("RoleToPermission")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Permission {
id String @id @default(uuid())
action String // Ví dụ: "create", "read", "update", "delete"
resource String // Ví dụ: "products", "orders", "users"
description String?
roles Role[] @relation("RoleToPermission")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([action, resource])
}Triển khai Middleware phân quyền động trong ExpressJS
Ở phía Backend, chúng ta cần xây dựng một Middleware trung gian để chặn các request và kiểm tra quyền hạn trước khi cho phép request đó đi vào Controller xử lý dữ liệu. Middleware này sẽ nhận vào hai tham số: action và resource.
Trước khi đi vào kiểm tra quyền, chúng ta giả định rằng đã có một Middleware xác thực (Authentication Middleware) để giải mã JWT và gán thông tin người dùng kèm theo danh sách quyền hạn vào đối tượng req.user.
import { Request, Response, NextFunction } from 'express';
interface UserPayload {
id: string;
email: string;
role: {
name: string;
permissions: {
action: string;
resource: string;
}[];
};
}
declare global {
namespace Express {
interface Request {
user?: UserPayload;
}
}
}
export const authorize = (action: string, resource: string) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({
success: false,
message: "Yêu cầu không được xác thực. Vui lòng đăng nhập."
});
}
// Quản trị viên tối cao (Super Admin) có toàn quyền truy cập hệ thống
if (user.role.name === 'Super Admin') {
return next();
}
// Kiểm tra xem danh sách quyền của người dùng có chứa quyền yêu cầu hay không
const hasPermission = user.role.permissions.some(
(permission) =>
permission.action === action && permission.resource === resource
);
if (!hasPermission) {
return res.status(403).json({
success: false,
message: `Bạn không có quyền thực hiện hành động ${action} trên tài nguyên ${resource}.`
});
}
return next();
} catch (error) {
return res.status(500).json({
success: false,
message: "Lỗi hệ thống khi kiểm tra quyền truy cập."
});
}
};
};Cách áp dụng Middleware này vào các Route trong ExpressJS vô cùng trực quan và dễ quản lý:
import { Router } from 'express';
import { authenticateJWT } from '../middlewares/auth';
import { authorize } from '../middlewares/authorize';
import { createProduct, getProducts, updateProduct } from '../controllers/product.controller';
const router = Router();
// Tất cả các route bên dưới đều yêu cầu đăng nhập
router.use(authenticateJWT);
router.get('/', authorize('read', 'products'), getProducts);
router.post('/', authorize('create', 'products'), createProduct);
router.put('/:id', authorize('update', 'products'), updateProduct);
export default router;Nâng cao: Giải quyết bài toán Quyền sở hữu tài nguyên (Resource Ownership)
Một vấn đề thực tế phát sinh: Một Vendor thông thường có quyền update:products, nhưng họ chỉ được phép sửa sản phẩm do chính họ tạo ra, chứ không được phép sửa sản phẩm của Vendor khác. Đây là lúc chúng ta cần nâng cấp hệ thống lên mức độ kiểm soát dựa trên thuộc tính (Attribute-Based Access Control - ABAC) hoặc kiểm tra quyền sở hữu (Ownership Check).
Chúng ta có thể giải quyết bằng cách bổ sung một tham số tùy chọn vào Middleware để xác định cách lấy ID của tài nguyên và kiểm tra quyền sở hữu trong cơ sở dữ liệu:
import { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface OwnershipConfig {
resourceIdParam: string;
checkOwnershipFn: (userId: string, resourceId: string) => Promise<boolean>;
}
export const authorizeWithOwnership = (
action: string,
resource: string,
ownershipConfig?: OwnershipConfig
) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ success: false, message: "Unauthorized" });
}
if (user.role.name === 'Super Admin') {
return next();
}
const hasPermission = user.role.permissions.some(
(p) => p.action === action && p.resource === resource
);
if (!hasPermission) {
return res.status(403).json({ success: false, message: "Forbidden" });
}
// Nếu có cấu hình kiểm tra quyền sở hữu và không phải Admin hệ thống
if (ownershipConfig && user.role.name !== 'Admin') {
const resourceId = req.params[ownershipConfig.resourceIdParam];
if (!resourceId) {
return res.status(400).json({ success: false, message: "Không tìm thấy ID tài nguyên để kiểm tra quyền sở hữu." });
}
const isOwner = await ownershipConfig.checkOwnershipFn(user.id, resourceId);
if (!isOwner) {
return res.status(403).json({
success: false,
message: "Bạn không có quyền thao tác trên tài nguyên không thuộc sở hữu của mình."
});
}
}
return next();
} catch (error) {
return res.status(500).json({ success: false, message: "Lỗi kiểm tra quyền sở hữu." });
}
};
};Ví dụ áp dụng kiểm tra quyền sở hữu khi cập nhật sản phẩm:
const checkProductOwnership = async (userId: string, productId: string): Promise<boolean> => {
const product = await prisma.product.findUnique({
where: { id: productId },
select: { vendorId: true }
});
return product?.vendorId === userId;
};
router.put(
'/:id',
authorizeWithOwnership('update', 'products', {
resourceIdParam: 'id',
checkOwnershipFn: checkProductOwnership
}),
updateProduct
);Đồng bộ và Kiểm soát hiển thị UI động trong React với Zustand
Ở phía Frontend, việc ẩn/hiển thị các nút bấm, menu điều hướng hoặc các trang dựa trên quyền hạn của người dùng là cực kỳ quan trọng để tối ưu hóa trải nghiệm người dùng (UX). Chúng ta không nên để người dùng click vào nút "Xóa" rồi mới nhận về lỗi 403 từ Backend.
Chúng ta sẽ sử dụng Zustand để quản lý trạng thái đăng nhập và danh sách quyền hạn của người dùng một cách tập trung và hiệu năng cao.
Xây dựng Auth Store với Zustand
import { create } from 'zustand';
interface Permission {
action: string;
resource: string;
}
interface User {
id: string;
email: string;
role: string;
}
interface AuthState {
user: User | null;
permissions: Permission[];
isAuthenticated: boolean;
setAuth: (user: User, permissions: Permission[]) => void;
clearAuth: () => void;
hasAccess: (action: string, resource: string) => boolean;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
permissions: [],
isAuthenticated: false,
setAuth: (user, permissions) => set({ user, permissions, isAuthenticated: true }),
clearAuth: () => set({ user: null, permissions: [], isAuthenticated: false }),
hasAccess: (action, resource) => {
const { user, permissions } = get();
if (!user) return false;
if (user.role === 'Super Admin') return true;
return permissions.some(
(p) => p.action === action && p.resource === resource
);
},
}));Xây dựng Component bảo vệ giao diện (PermissionGuard)
Để tái sử dụng khả năng kiểm tra quyền trên giao diện, chúng ta xây dựng một Component Wrapper có tên là PermissionGuard. Component này sẽ bọc các phần tử UI nhạy cảm và chỉ hiển thị chúng nếu người dùng có đủ quyền hạn.
import React from 'react';
import { useAuthStore } from '../stores/useAuthStore';
interface PermissionGuardProps {
action: string;
resource: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export const PermissionGuard: React.FC<PermissionGuardProps> = ({
action,
resource,
children,
fallback = null
}) => {
const hasAccess = useAuthStore((state) => state.hasAccess(action, resource));
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
};Sử dụng PermissionGuard trong các Component React của bạn để ẩn/hiển thị các chức năng một cách linh hoạt:
import React from 'react';
import { PermissionGuard } from './PermissionGuard';
export const ProductRow = ({ product }) => {
return (
<tr>
<td>{product.name}</td>
<td>{product.price}</td>
<td>
<div className="flex space-x-2">
<PermissionGuard action="update" resource="products">
<button className="btn-edit">Chỉnh sửa</button>
</PermissionGuard>
<PermissionGuard
action="delete"
resource="products"
fallback={<span className="text-gray-400 text-sm">Không có quyền xóa</span>}
>
<button className="btn-delete">Xóa</button>
</PermissionGuard>
</div>
</td>
</tr>
);
};Tối ưu hóa hiệu năng và bảo mật cho hệ thống phân quyền
Khi triển khai Dynamic RBAC, có hai vấn đề kỹ thuật quan trọng cần lưu ý để đảm bảo hệ thống hoạt động mượt mà và an toàn:
1. Caching quyền hạn để tránh nghẽn Database
Việc truy vấn cơ sở dữ liệu để lấy danh sách quyền hạn của người dùng trên mỗi request HTTP sẽ tạo ra một tải trọng rất lớn cho MySQL khi số lượng người dùng đồng thời tăng cao. Để giải quyết vấn đề này, hãy áp dụng cơ chế Caching bằng Redis hoặc Memory Cache.
Khi người dùng đăng nhập thành công, hãy lưu danh sách quyền hạn của họ vào Redis với key là ID của người dùng và thời gian hết hạn (TTL) bằng với thời gian hết hạn của JWT. Khi Middleware kiểm tra quyền, nó sẽ đọc trực tiếp từ Redis thay vì truy vấn MySQL.
2. Đồng bộ hóa trạng thái Frontend và Backend
Hãy luôn nhớ rằng việc ẩn nút bấm ở Frontend chỉ là giải pháp tối ưu trải nghiệm người dùng (UX), không phải là giải pháp bảo mật. Người dùng am hiểu công nghệ vẫn có thể mở Developer Tools để hiển thị lại nút bấm hoặc gửi request trực tiếp bằng các công cụ như Postman. Do đó, việc kiểm tra quyền ở Backend thông qua các Middleware bảo mật luôn là bắt buộc và là chốt chặn cuối cùng.
Kết luận
Thiết kế một hệ thống Dynamic RBAC và Fine-Grained Access Control chuẩn chỉnh giúp ứng dụng của bạn có khả năng mở rộng cực kỳ mạnh mẽ, dễ dàng đáp ứng các yêu cầu phân quyền phức tạp từ phía doanh nghiệp mà không cần phải tái cấu trúc hay sửa đổi quá nhiều mã nguồn. Việc kết hợp chặt chẽ giữa Prisma ở tầng dữ liệu, ExpressJS ở tầng xử lý nghiệp vụ và Zustand ở tầng hiển thị tạo nên một luồng xử lý dữ liệu nhất quán, an toàn và tối ưu hiệu năng.
Nếu bạn muốn học cách xây dựng một hệ thống quản trị hoàn chỉnh từ đầu, áp dụng các công nghệ hiện đại như ReactJS, ExpressJS, TypeScript, Prisma và Docker vào một dự án thực tế quy mô lớn, hãy tham khảo khóa học chuyên sâu dưới đây để nâng cao kỹ năng lập trình fullstack của mình. Tham khảo khóa học "[Full Course] Ecommerce Dashboard Fullstack Clone" tại đây.







Bình luận 0
Chia sẻ ý kiến hoặc đặt câu hỏi cùng cộng đồng