Spring Security & Testing
15/20
Bài 15 / 20~12 phútAuthorization & Web SecurityLộ trình · chặng 18/21Miễn phí… lượt xem

Lọc Dữ Liệu Method Security và PermissionEvaluator

Khám phá @PreFilter, @PostFilter, rủi ro tải dữ liệu in-memory và triển khai phân quyền chi tiết với PermissionEvaluator trong Spring Security 6.

TL;DR: @PreFilter và @PostFilter hỗ trợ lọc từng phần tử trong tập dữ liệu đầu vào hoặc đầu ra theo biểu thức SpEL. Tuy nhiên, @PostFilter tải toàn bộ bản ghi từ cơ sở dữ liệu lên bộ nhớ rồi mới loại bỏ phần tử vi phạm, dẫn đến nguy cơ tràn bộ nhớ heap (OOM) và gây áp lực thu gom rác (GC) lớn trên các tập dữ liệu thực tế. Với danh sách lớn, giải pháp chuẩn mực là đẩy điều kiện lọc xuống câu lệnh SQL (WHERE). Để phân quyền chi tiết theo từng đối tượng nghiệp vụ (Domain Object Security), Spring Security cung cấp PermissionEvaluator giúp cô lập logic kiểm tra quyền vào một lớp Java độc lập, kết hợp cùng kiến trúc AuthorizationManager hiện đại trong Spring Security 6.

Tại sao việc lọc tập dữ liệu bằng @PostFilter lại tiềm ẩn rủi ro hiệu năng?

Trong các ứng dụng thực tế, bài toán phổ biến là người dùng thông thường chỉ được xem các dự án mà họ là thành viên, trong khi quản trị viên có quyền xem toàn bộ. Annotation @PostFilter cho phép đặt điều kiện lọc ngay trên phương thức truy vấn:

Java
// Spring se duyet tung phan tu trong collection sau khi method chay xong
@PostFilter("filterObject.owner.email == authentication.name or hasRole('ADMIN')")
public List<Project> findAll() {
    return projectRepository.findAll(); // Truy van toan bo bang du lieu
}

Trong biểu thức SpEL trên:

  • authentication.name đại diện cho định danh người dùng hiện tại lấy từ SecurityContextHolder.
  • filterObject là biến ngữ cảnh đại diện cho từng phần tử đang được duyệt qua trong danh sách kết quả.

Cơ chế load-then-filter và áp lực bộ nhớ

AOP Proxy bọc ngoài phương thức findAll(). Sau khi phương thức gốc hoàn thành và trả về danh sách, PostFilterAuthorizationMethodInterceptor sẽ duyệt qua từng phần tử, đánh giá SpEL và gọi Iterator.remove() đối với phần tử không thỏa mãn:

Hai cột đối chiếu cùng trả về 5 row: cột @PostFilter chạy SELECT sao chép toàn bảng, Hibernate hydrate 10 000 object lên heap khoảng 20 MB, rồi interceptor eval SpEL và xoá 9 995 object vừa tạo; cột query at DB đặt điều kiện owner_email vào SQL nên DB chỉ trả 5 row và không có gì cho GC dọn

Nếu bảng dữ liệu có 10.000 bản ghi nhưng người dùng chỉ sở hữu 5 bản ghi:

  1. ORM (Hibernate) vẫn thực thi SELECT * FROM projects, kéo toàn bộ 10.000 dòng qua mạng.
  2. 10.000 đối tượng Java được khởi tạo và chiếm dụng khoảng 20 MB bộ nhớ heap.
  3. Interceptor loại bỏ 9.995 đối tượng vừa tạo để trả về 5 đối tượng.
  4. Bộ thu gom rác (Garbage Collector) phải dọn dẹp hàng nghìn đối tượng ngắn ngày.

Khi có 100 yêu cầu đồng thời, hệ thống sẽ đối mặt với đợt tăng đột biến 2 GB heap, gây nguy cơ treo ứng dụng do OutOfMemoryError.

Chiến lược lựa chọn phương án lọc dữ liệu

Quy tắc phân định ranh giới lọc dữ liệu

Chỉ sử dụng @PostFilter khi tập dữ liệu có kích thước nhỏ cố định (dưới 100 phần tử như Top 10, Menu điều hướng) hoặc quy tắc nghiệp vụ quá phức tạp không thể viết bằng SQL. Khi tập dữ liệu không giới hạn kích thước, bắt buộc lọc bằng câu lệnh truy vấn cơ sở dữ liệu.

Đặc điểm đối chiếu@PostFilter (In-memory)Lọc tại Database (WHERE)
Vị trí xử lýSau khi phương thức trả về kết quảNgay trong câu lệnh SQL tại Database Engine
Mức tiêu hao HeapTỉ lệ thuận với tổng số bản ghi trong bảngTỉ lệ thuận với số bản ghi người dùng sở hữu
Sử dụng Database IndexKhông tận dụng được index cho quyền truy cậpTối ưu hóa triệt để qua chỉ mục index
Hỗ trợ phân trangPhá vỡ kích thước trang (Page size bị hụt)Tương thích hoàn hảo với Pageable

Giải pháp tối ưu tại tầng cơ sở dữ liệu:

Java
public interface ProjectRepository extends JpaRepository<Project, Long> {
    // Loc chinh xac tai database qua menh de WHERE
    @Query("""
        SELECT p FROM Project p 
        WHERE p.owner.email = :email 
           OR :email MEMBER OF p.memberEmails
    """)
    List<Project> findAccessibleProjects(@Param("email") String email);
}

Cơ chế hoạt động của @PreFilter và giải pháp thay thế ném ngoại lệ là gì?

Ngược lại với @PostFilter, annotation @PreFilter can thiệp vào tham số đầu vào là Collection trước khi phương thức nghiệp vụ bắt đầu thực thi:

Java
// Loc bo nhung yeu cau khong hop le truoc khi xu ly
@PreFilter("filterObject.amount <= 1000000 or hasRole('ADMIN')")
public void processTransfers(List<TransferRequest> transfers) {
    transfers.forEach(this::executeTransfer);
}

Hiện tượng Silent Strip và rủi ro nghiệp vụ

@PreFilter âm thầm xóa bỏ các phần tử không thỏa mãn khỏi danh sách đầu vào mà không đưa ra cảnh báo. Nếu phía client gửi lên danh sách 10 giao dịch nhưng có 2 giao dịch bị loại bỏ, API vẫn trả về mã trạng thái 200 OK. Phía gọi ngầm hiểu toàn bộ 10 giao dịch đã thành công, dẫn đến sai lệch dữ liệu tài chính nghiêm trọng.

Thay thế bằng @PreAuthorize và SpEL Collection Projection

Để thông báo lỗi rõ ràng, ta sử dụng cú pháp lọc tập hợp của SpEL (.?[...]) trong @PreAuthorize để từ chối toàn bộ yêu cầu nếu phát hiện bất kỳ phần tử nào vi phạm:

Java
// Nem AccessDeniedException neu co bat ky phan tu nao vuot han muc
@PreAuthorize("#transfers.?[amount > 1000000].size() == 0 or hasRole('ADMIN')")
public void processTransfers(List<TransferRequest> transfers) {
    transfers.forEach(this::executeTransfer);
}

Biểu thức #transfers.?[amount > 1000000] lọc ra danh sách các giao dịch vượt hạn mức. Nếu kích thước danh sách này lớn hơn 0, Spring Security lập tức ném AccessDeniedException và chặn đứng luồng xử lý.

Kiến trúc AuthorizationManager trong Spring Security 6 thay thế hệ thống cũ như thế nào?

Spring Security 6 đã loại bỏ hoàn toàn hệ thống bầu chọn phức tạp AccessDecisionManager và các AccessDecisionVoter của phiên bản 5, thay thế bằng giao diện hàm tinh gọn AuthorizationManager<T>:

Java
@FunctionalInterface
public interface AuthorizationManager<T> {
    AuthorizationDecision check(Supplier<Authentication> authentication, T object);
}

Các ưu điểm vượt trội của AuthorizationManager

  1. Giao diện hàm linh hoạt (Functional Interface): Dễ dàng triển khai thông qua cú pháp Lambda hoặc Method Reference mà không cần kế thừa nhiều lớp phức tạp.
  2. Hỗ trợ Generic an toàn kiểu: T có thể là MethodInvocation cho bảo mật phương thức, hoặc RequestAuthorizationContext cho phân quyền URL.
  3. Cơ chế tải trễ (Lazy Authentication): Nhận vào Supplier<Authentication>, chỉ thực hiện truy vấn thông tin định danh khi quy tắc bảo mật thực sự cần kiểm tra, tối ưu hóa các endpoint công khai.
  4. Kết hợp linh hoạt qua allOf và anyOf: Cho phép ghép nối các điều kiện phân quyền độc lập mà không cần tạo wrapper class.
Java
// Ket hop quy tac: Hoac la ADMIN hoac la chu so huu du an
AuthorizationManager<MethodInvocation> projectRule = AuthorizationManagers.anyOf(
    AuthorityAuthorizationManager.hasRole("ADMIN"),
    new ProjectOwnerAuthorizationManager(projectRepository)
);

Triển khai phân quyền chi tiết qua PermissionEvaluator và biểu thức hasPermission ra sao?

Khi ứng dụng mở rộng với nhiều thực thể nghiệp vụ (Project, Task, Comment) và các cấp độ thao tác đa dạng (READ, WRITE, DELETE), việc nhúng toàn bộ logic kiểm tra vào SpEL sẽ khiến code khó bảo trì:

Java
// SpEL qua dai, kho doc va khong the viet Unit Test doc lap
@PreAuthorize("""
    hasRole('ADMIN') or 
    (@projectRepository.findById(#id).orElse(null)?.ownerEmail == authentication.name)
""")
public void updateProject(Long id, ProjectRequest request) { ... }

Giao diện PermissionEvaluator của Spring Security giải quyết bài toán này bằng cách chuẩn hóa cú pháp kiểm tra quyền hạn theo mô hình: (Target Object/ID, Permission).

Java
public interface PermissionEvaluator {
    boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission);
    boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission);
}

Xây dựng lớp AppPermissionEvaluator chuẩn mực

Java
@Component
public class AppPermissionEvaluator implements PermissionEvaluator {

    private final ProjectRepository projectRepository;

    public AppPermissionEvaluator(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }

    @Override
    public boolean hasPermission(Authentication auth, Object target, Object permission) {
        if (target instanceof Project project) {
            return checkProjectPermission(auth, project, (String) permission);
        }
        return false;
    }

    @Override
    public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
        if ("Project".equals(targetType)) {
            return projectRepository.findById((Long) targetId)
                .map(project -> checkProjectPermission(auth, project, (String) permission))
                .orElse(false);
        }
        return false;
    }

    private boolean checkProjectPermission(Authentication auth, Project project, String permission) {
        if (hasRole(auth, "ROLE_ADMIN")) return true;
        
        String username = auth.getName();
        boolean isOwner = project.getOwnerEmail().equals(username);
        boolean isMember = project.getMemberEmails().contains(username);

        return switch (permission) {
            case "READ" -> isOwner || isMember;
            case "WRITE", "DELETE" -> isOwner;
            default -> false;
        };
    }

    private boolean hasRole(Authentication auth, String role) {
        return auth.getAuthorities().stream()
            .anyMatch(granted -> granted.getAuthority().equals(role));
    }
}

Sau khi triển khai, các phương thức nghiệp vụ chỉ cần sử dụng biểu thức SpEL ngắn gọn và rõ nghĩa:

Java
@Service
public class ProjectService {
    // Truyen ID va loai doi tuong
    @PreAuthorize("hasPermission(#projectId, 'Project', 'WRITE')")
    public void updateTitle(Long projectId, String newTitle) { ... }

    // Truyen truc tiep thuc the da co san de tranh truy van duplicate
    @PreAuthorize("hasPermission(#project, 'DELETE')")
    public void remove(Project project) { ... }
}

Vì sao biểu thức hasPermission có thể luôn trả về false và cách chẩn đoán lỗi?

Một lỗi thường gặp khi triển khai PermissionEvaluator là mọi lời gọi hasPermission đều bị từ chối với mã lỗi 403 Forbidden, dù logic kiểm tra hoàn toàn chính xác.

Mô hình chẩn đoán ba tầng xác định nguyên nhân:

Cây quyết định bốn tầng khi SpEL gọi hasPermission: thiếu @EnableMethodSecurity thì mọi annotation no-op và method chạy không check, thiếu bean MethodSecurityExpressionHandler thì DenyAllPermissionEvaluator trả false và 403, quên gọi setPermissionEvaluator thì handler vẫn DenyAll và cũng 403; chỉ khi cả ba tầng đều đạt thì evaluator của bạn mới được hỏi và true hay false ở đó mới là quyền thật

Nguyên nhân cốt lõi là Spring Security sử dụng DenyAllPermissionEvaluator làm cấu hình mặc định. Để kích hoạt evaluator tùy biến, bạn bắt buộc phải đăng ký bean MethodSecurityExpressionHandler:

Java
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
        AppPermissionEvaluator permissionEvaluator
    ) {
        DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
        handler.setPermissionEvaluator(permissionEvaluator);
        return handler;
    }
}

Trình tự chẩn đoán lỗi khi gặp 403 Forbidden

  1. Kiểm tra @EnableMethodSecurity: Đảm bảo annotation này đã hiện diện trên lớp cấu hình chính.
  2. Kiểm tra đăng ký Handler Bean: Xác nhận MethodSecurityExpressionHandler đã được đưa vào Application Context và đã gọi setPermissionEvaluator.
  3. Đặt điểm dừng (Breakpoint): Kiểm tra xem phương thức hasPermission() trong evaluator có được kích hoạt khi request đi qua hay không.

Các annotation tiền thân @Secured và @RolesAllowed khác biệt gì so với @PreAuthorize?

Trong các hệ thống kế thừa (Legacy Systems), bạn có thể bắt gặp các annotation phân quyền cũ hơn. Việc hiểu rõ bản chất giúp bạn bảo trì và chuyển đổi mã nguồn hiệu quả:

Tiêu chí@PreAuthorize@RolesAllowed@Secured
Nguồn gốcSpring Security 3+Chuẩn JSR-250 (Jakarta)Spring Security 1.x / 2.x
Kích hoạtMặc định trong @EnableMethodSecurityjsr250Enabled = truesecuredEnabled = true
Cú pháp tiền tốNhận ADMIN trong hasRole('ADMIN')Nhận trực tiếp ADMINBắt buộc tiền tố ROLE_ADMIN
Hỗ trợ SpELĐầy đủ (Method args, Bean, Logic)Không hỗ trợKhông hỗ trợ

Khuyến nghị hiện đại: Luôn sử dụng @PreAuthorize làm lựa chọn mặc định duy nhất cho các dự án mới nhằm đảm bảo tính linh hoạt tối đa.

Những điểm then chốt cần ghi nhớ là gì?

  • Luôn phân biệt ranh giới giữa lọc dữ liệu in-memory (@PostFilter) và lọc tại cơ sở dữ liệu (WHERE).
  • Không sử dụng @PostFilter trên các API có phân trang (Pageable) vì sẽ làm sai lệch kích thước trang.
  • Tránh cạm bẫy Silent Strip của @PreFilter bằng cách sử dụng @PreAuthorize với SpEL .?[...] để chủ động ném ngoại lệ khi có dữ liệu vi phạm.
  • Tận dụng PermissionEvaluator để đóng gói logic phân quyền đối tượng nghiệp vụ phức tạp, giúp mã nguồn tầng Service luôn trong sáng.
  • Bắt buộc đăng ký MethodSecurityExpressionHandler để liên kết custom evaluator vào Spring Security Context.

Bạn đã làm chủ cơ chế lọc và PermissionEvaluator chưa?

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao việc sử dụng @PostFilter trên một bảng dữ liệu lớn (ví dụ 100.000 dòng) lại bị coi là một anti-pattern nghiêm trọng về hiệu năng?
  2. Q2
    Hiện tượng 'Silent Strip' khi sử dụng @PreFilter trên danh sách tham số đầu vào gây ra rủi ro gì?
  3. Q3
    Cú pháp SpEL nào cho phép kiểm tra danh sách đầu vào và ném ngoại lệ ngay lập tức nếu có bất kỳ phần tử nào vi phạm điều kiện số tiền?
  4. Q4
    Nếu triển khai Custom PermissionEvaluator nhưng quên cấu hình MethodSecurityExpressionHandler, điều gì sẽ xảy ra khi gọi hasPermission trong SpEL?
  5. Q5
    Tại sao kiến trúc AuthorizationManager trong Spring Security 6 lại ưu tiên nhận tham số Supplier<Authentication> thay vì trực tiếp đối tượng Authentication?

Bài tiếp theo: CORS — Cơ Chế Bảo Mật Trình Duyệt và Cấu Hình

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 18/21 · Authentication & Authorization — bài 15/19 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

CORS — Same-Origin Policy, Preflight và Spring Security