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
| Property | Mô tả | Mặc định |
|---|---|---|
govex.data-permission.enabled | Bật tính năng phân quyền dữ liệu; chỉ tắt khi đặt false | true |
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ính | Bắt buộc | Mặc định | Ý nghĩa |
|---|---|---|---|
targetEntity | Có | — | Entity của đối tượng dữ liệu cần kiểm tra. |
id | Có | — | Đườ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. |
operation | Không | READ | Loại thao tác: READ, CREATE, UPDATE, DELETE. |
ruleNames | Không | mảng rỗng | Danh sách tên rule áp dụng; rỗng nghĩa là mọi rule đã đăng ký. |
applyMode | Không | INCLUDE | INCLUDE chỉ dùng rule liệt kê; EXCLUDE dùng mọi rule trừ rule liệt kê. |
combineMode | Không | ALL | ALL mọi rule phải đồng ý; ANY chỉ cần một rule đồng ý. |
skip | Không | false | Bỏ 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ácUPDATE/DELETEmà 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épgetFilterExpression(...)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ặcANY(OR).
Nguyên tắc fail-closed trong DefaultDataPermissionService:
- Chưa có rule nào được đăng ký → ném
DataPermissionException. applyMode = INCLUDEvà không tìm thấy rule khớp tên → némDataPermissionException.- Rule trả
getFilterExpression(...)lànull→ bỏ qua rule đó kèm cảnh báo (không ghép điều kiện). applyMode = EXCLUDEloạ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émDataPermissionExceptionthay 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ụidvà tham số ngữ cảnh. UPDATE/DELETEbắ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.
DataPermissionContextHolderlưu ngữ cảnh theo ThreadLocal; khi xử lý bất đồng bộ phải lấy bản sao bằnggetCopyOfContext()và truyền sang luồng mới, gọiclear()sau khi xong để tránh rò rỉ dữ liệu.- Dependency phụ thuộc
govex-cloud-data-jpa(SQLExpression) vàgovex-cloud-security.