Chuyển tới nội dung chính

Phân quyền dữ liệu

Phân quyền dữ liệu theo rule: kiểm tra quyền trên từng đối tượng và tự ghép điều kiện lọc/danh sách trường được phép vào câu truy vấn. Dùng khi ứng dụng cần giới hạn dữ liệu người dùng nhìn thấy hoặc thao tác, bổ sung cho phân quyền chức năng ở tầng API.

Khi nào sử dụng

  • Cần chặn thao tác UPDATE/DELETE trên đối tượng mà người dùng không có quyền.
  • Cần tự động lọc dữ liệu trả về theo phạm vi được phép (ví dụ chỉ thấy bản ghi do mình quản lý).
  • Cần giới hạn danh sách trường được phép đọc trên từng bảng dữ liệu.
  • Muốn đóng gói chính sách phân quyền dữ liệu thành các rule độc lập, dễ chọn theo chức năng.

Cài đặt

<dependency>
<groupId>vn.govex.cloud</groupId>
<artifactId>govex-cloud-data-permission</artifactId>
</dependency>

Version quản lý qua BOM — xem Cài đặt. Dependency phụ thuộc govex-cloud-data-jpa (SQLExpression) và govex-cloud-security.

Cấu hình

PropertyMô tảMặc định
govex.data-permission.enabledBật tính năng phân quyền dữ liệu; chỉ tắt khi đặt falsetrue

Service và aspect cho phép ghi đè bằng bean tự định nghĩa. Khi tính năng bật nhưng chưa có rule nào được đăng ký, cảnh báo được ghi lúc khởi động; lúc chạy, thao tác kiểm tra quyền sẽ bị từ chối theo nguyên tắc fail-closed.

Sử dụng

Bước 1 — Đánh dấu method cần kiểm tra quyền bằng @DataPermission

@PutMapping("/{deviceToken}")
@DataPermission(targetEntity = Device.class, id = "#deviceToken")
public ResultMessage<Void> revoke(@PathVariable("deviceToken") String deviceToken,
@RequestParam Integer status) {
deviceService.updateStatus(deviceToken, status);
return ResultMessage.success();
}
Thuộc tínhBắt buộcMặc địnhÝ nghĩa
targetEntityEntity của đối tượng dữ liệu cần kiểm tra.
idĐường dẫn xác định ID đối tượng: tên tham số (id), đường dẫn lồng nhau (entity.id) hoặc SpEL (#pathVariable.id); để trống khi chỉ cần lọc dữ liệu.
operationKhôngREADLoại thao tác: READ, CREATE, UPDATE, DELETE.
ruleNamesKhôngmảng rỗngDanh sách tên rule áp dụng; rỗng nghĩa là mọi rule đã đăng ký.
applyModeKhôngINCLUDEINCLUDE chỉ dùng rule liệt kê; EXCLUDE dùng mọi rule trừ rule liệt kê.
combineModeKhôngALLALL mọi rule phải đồng ý; ANY chỉ cần một rule đồng ý.
skipKhôngfalseBỏ qua kiểm tra quyền cho method.

id được xác định từ tham số method nên cần biên dịch với -parameters; biểu thức sai hoặc không đọc được thuộc tính sẽ ném DataPermissionException.

Bước 2 — Dùng annotation rút gọn cho thao tác đọc

@ReadDataPermission là meta-annotation của @DataPermission với operation = READ; các thuộc tính còn lại ánh xạ trực tiếp qua @AliasFor:

@ReadDataPermission(targetEntity = Device.class)
public List<RegisterDeviceDTO> search(RegisterDeviceSearchParam param) {
// câu truy vấn bên trong được tự động ghép điều kiện lọc theo rule
return deviceRepository.findAll();
}

Annotation này đặt trên method service (aspect xử lý cả annotation trực tiếp lẫn meta-annotation). Khi thao tác đọc không có ID, điều kiện lọc và danh sách trường được phép được ghép vào biểu thức SQL đang thi hành.

Bước 3 — Hiện thực rule

Rule hiện thực interface DataPermissionRule:

MethodÝ nghĩa
getRuleName()Tên duy nhất của rule.
hasPermission(PermissionContext context)Kiểm tra quyền trên đối tượng theo context.getEntityId(); trả PermissionResult.allowed()/denied(...).
getFilterExpression(PermissionContext context)Điều kiện lọc ghép vào truy vấn đọc không có ID; trả null nếu rule không giới hạn dữ liệu.
getAllowFields(PermissionContext context)Danh sách trường được phép theo cú pháp GraphQL-like.
getOrder()Độ ưu tiên, số nhỏ xử lý trước; mặc định Ordered.LOWEST_PRECEDENCE.

Kế thừa AbstractDataPermissionRule để rule tự đăng ký vào DataPermissionRuleRegistry sau khi bean được khởi tạo (@PostConstruct), không cần đăng ký thủ công:

@Component
public class DeviceOwnerRule extends AbstractDataPermissionRule {

@Override
public String getRuleName() {
return "device-owner";
}

@Override
public PermissionResult hasPermission(PermissionContext context) {
if (context.getEntityId() == null) {
return PermissionResult.denied("Thiếu ID thiết bị để kiểm tra quyền");
}
boolean owned = deviceService.isOwnedBy(
String.valueOf(context.getEntityId()), SecurityUtils.getCurrentUserId());
return owned
? PermissionResult.allowed()
: PermissionResult.denied("Thiết bị không thuộc người dùng hiện tại");
}

@Override
public SQLExpression<?> getFilterExpression(PermissionContext context) {
return SQLExpressions.where().equal("userId", SecurityUtils.getCurrentUserId());
}

@Override
public String getAllowFields(PermissionContext context) {
return "device{deviceToken,deviceOs,status}";
}

@Override
public int getOrder() {
return 10;
}
}

DataPermissionRuleRegistry là registry tĩnh dùng chung: đăng ký trùng tên bị bỏ qua kèm cảnh báo; unregisterRule(...) để gỡ rule; getRegisterRules() trả danh sách đã sắp theo getOrder().

Bước 4 — Trả kết quả và nhận ngữ cảnh trong rule

PermissionResult:

  • PermissionResult.allowed() — có quyền.
  • PermissionResult.denied(reason) — từ chối kèm lý do; denied() không kèm lý do.

Rule trả null bị coi như không đồng ý (có log cảnh báo).

PermissionContext cung cấp:

Thành phầnÝ nghĩa
getEntityClass() / getEntityId()Entity và ID đối tượng đang kiểm tra.
getOperation()OperationType của lần kiểm tra.
getRuleNames() / getApplyMode() / getCombineMode()Cấu hình rule lấy từ annotation.
getAllowEntityFields() / getAllowFields(tableName)Tập trường được phép theo tên bảng.
getParam(key) / hasParam(key) / addParam(key, value)Tham số bổ sung; aspect tự nạp tham số method theo tên kèm methodName, className.
isReadOperation() / isUpdateOperation() / isDeleteOperation()Kiểm tra nhanh loại thao tác.

Bước 5 — Cách aspect áp dụng rule

Với mỗi lời gọi method có @DataPermission (hoặc meta-annotation), DataPermissionAspect:

  • Xác định ID đối tượng từ id; thao tác UPDATE/DELETE mà không xác định được ID sẽ bị từ chối ngay.
  • Có ID: gọi checkDataPermission(...); thêm danh sách trường được phép khi thao tác là READ/UPDATE.
  • Không có ID và là thao tác READ: ghép getFilterExpression(...) rồi ghép danh sách trường được phép vào biểu thức SQL hiện hành.
  • Phục hồi ngữ cảnh phân quyền và biểu thức SQL trước đó sau khi method kết thúc, hỗ trợ gọi lồng nhau.

Cách kết hợp kết quả từ nhiều rule:

  • combineMode = ALL — mọi rule phải đồng ý; chỉ cần một rule từ chối là từ chối.
  • combineMode = ANY — chỉ cần một rule đồng ý là cho phép.
  • Với điều kiện lọc, biểu thức của nhiều rule cũng được ghép theo ALL (AND) hoặc ANY (OR).

Nguyên tắc fail-closed trong DefaultDataPermissionService:

  • Chưa có rule nào được đăng ký → ném DataPermissionException.
  • applyMode = INCLUDE và không tìm thấy rule khớp tên → ném DataPermissionException.
  • Rule trả getFilterExpression(...)null → bỏ qua rule đó kèm cảnh báo (không ghép điều kiện).
  • applyMode = EXCLUDE loại hết rule → ghi cảnh báo và không ghép điều kiện lọc.

Cú pháp getAllowFields

getAllowFields(...) trả chuỗi dạng GraphQL-like, ví dụ device{deviceToken,deviceOs,status}:

  • Tên bảng/entity đứng trước, danh sách trường trong cặp ngoặc nhọn.
  • * đại diện cho tất cả trường; ! loại trừ một trường (departments{!code}).
  • Tên trong biểu thức có thể là tên field Java hoặc tên cột.
  • Không liệt kê trường cụ thể (chỉ tên bảng) nghĩa là mọi trường đều được phép.

Ví dụ thật trong demo

demo/customer dùng đúng API này.

Trên controller — chặn cập nhật trạng thái thiết bị không thuộc quyền:

@PutMapping("/{deviceToken}")
@Operation(summary = "cập nhật trạng thái thiết bị")
@DataPermission(targetEntity = Device.class, id = "#deviceToken")
public ResultMessage<Void> revoke(@PathVariable("deviceToken") String deviceToken,
@RequestParam Integer status) {
deviceService.updateStatus(deviceToken, status);
return ResultMessage.success();
}

Trên service — truy vấn danh sách thiết bị được lọc theo rule:

@Override
@ReadDataPermission(targetEntity = Device.class)
public List<RegisterDeviceDTO> search(RegisterDeviceSearchParam param) {
return repository.findActiveByNativeSpec(SQLExpressions.<Device>ofBeanTyped(param, Paths.field(Device::getDeviceType).build())
.equal(Device::getDeviceToken, param.getDeviceToken())
.like(Device::getDeviceOs, param.getDeviceOs())
.in(Device::getStatus, param.getStatuses()));
}

Luồng hoạt động

Sơ đồ dưới đây mô tả luồng phân quyền: aspect đọc annotation, xác định ID đối tượng và ghép điều kiện lọc/trường được phép vào truy vấn.

Lưu ý

  • Nguyên tắc fail-closed: không có rule nào được đăng ký, hoặc không tìm thấy rule phù hợp ở chế độ INCLUDE → ném DataPermissionException thay vì bỏ qua kiểm tra.
  • Mã lỗi: PM001 (403) từ chối chung, PM002 (403) từ chối đọc, PM003 (403) từ chối cập nhật, PM004 (403) từ chối xóa (DataPermissionResultCodes).
  • Aspect hoạt động qua proxy: lời gọi nội bộ trong cùng một bean (self-invocation) không đi qua aspect; cần gọi qua bean khác để kiểm tra quyền được áp dụng.
  • Cần biên dịch với -parameters để aspect đọc được tên tham số phục vụ id và tham số ngữ cảnh.
  • UPDATE/DELETE bắt buộc xác định được ID đối tượng, nếu không sẽ bị từ chối.
  • Rule đăng ký trùng tên bị bỏ qua kèm cảnh báo.
  • DataPermissionContextHolder lưu ngữ cảnh theo ThreadLocal; khi xử lý bất đồng bộ phải lấy bản sao bằng getCopyOfContext() và truyền sang luồng mới, gọi clear() sau khi xong để tránh rò rỉ dữ liệu.
  • Dependency phụ thuộc govex-cloud-data-jpa (SQLExpression) và govex-cloud-security.