Spring Security & Testing/Method security filtering — @PreFilter, @PostFilter, AuthorizationManager, PermissionEvaluator
15/20
Bài 15 / 20~14 phútAuthorization & Web SecurityMiễn phí lượt xem

Method security filtering — @PreFilter, @PostFilter, AuthorizationManager, PermissionEvaluator

Lọc collection theo authority với @PreFilter/@PostFilter, cảnh giác @PostFilter trên large dataset (load-then-filter in-memory), AuthorizationManager API Spring Security 6, custom PermissionEvaluator cho fine-grained domain object authz, @Secured/@RolesAllowed legacy.

TL;DR: @PreFilter / @PostFilter cho phép lọc từng element của collection đầu vào / đầu ra theo SpEL expression — nhưng @PostFilter load tất cả record trước rồi mới filter in-memory, gây memory spike và GC pressure khi dataset lớn. Với collection vượt ~100 phần tử nên filter tại DB (thêm WHERE vào query). AuthorizationManager là API Spring Security 6 thay thế AccessDecisionManager cũ — functional, generic, compose qua allOf/anyOf. PermissionEvaluator cho phép khai báo hasPermission(target, "WRITE") trong SpEL và tập trung logic ownership check vào một class test được. @Secured/@RolesAllowed là annotation legacy — chỉ cần nhận ra khi đọc code cũ.

Bài này tiếp nối AOP proxy và @PreAuthorize — nếu chưa rõ vì sao annotation hoạt động qua proxy, hãy đọc bài đó trước.

1. Scenario — tại sao cần filter collection?

Giả sử TaskFlow có endpoint trả danh sách project của toàn team. Nhưng rule business yêu cầu: USER thường chỉ thấy project họ là thành viên, ADMIN thấy tất cả. Có 3 cách implement, và chi phí của chúng không cùng bậc:

  • @PostFilter in-memory — load hết row rồi drop trong Java. Chi phí tỉ lệ với tổng số row trong bảng, không phải số row user được xem.
  • Query at DB — thêm WHERE vào SQL, một round-trip trả đúng phần user có quyền. Chi phí không phụ thuộc kích thước bảng.
  • Custom AuthorizationManager — check một lần cho mỗi lời gọi method, không có chi phí per-element. Dùng cho single-resource access, không dùng để lọc list.

Biết khi nào dùng cách nào là phần cốt lõi bài này.

2. @PostFilter — lọc collection kết quả

@PostFilter cho phép khai báo rule lọc ngay trên method: Spring chạy method → nhận collection kết quả → eval SpEL expression filterObject (mỗi element) → giữ element nếu true, drop nếu false.

// Moi element: chi giu neu user la owner
@PostFilter("filterObject.owner.email == authentication.name")
public List<Project> findAll() {
    return repo.findAll();   // load tat ca truoc
}

authentication.name là tên user hiện tại từ SecurityContextHolder. filterObject là biến Spring đặt cho từng element khi eval.

2.1 Cơ chế bên dưới — load hết, drop sau

AOP proxy wrap method findAll(). Sau khi method thật trả về List<Project>, proxy gọi PostFilterAuthorizationMethodInterceptor — nó duyệt qua từng element, eval SpEL, gọi list.remove() cho element trả về false. Collection bị mutate in-place:

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

Vấn đề rõ ràng: 10 000 row đã được load khỏi DB lên JVM heap, chỉ 5 row được trả về caller. 9 995 object sống ngắn trên heap → GC pressure. Network traffic DB → app vô ích.

2.2 Cảnh giác @PostFilter với large dataset

@PostFilter chỉ dành cho list nhỏ — dataset lớn PHẢI filter tại DB
// SAI -- load toan bo bang len heap roi moi drop
@PostFilter("filterObject.owner.email == authentication.name")
public List<Project> findAll() {
    return repo.findAll();
}

// DUNG -- dieu kien ownership nam ngay trong SQL
public interface ProjectRepository extends JpaRepository<Project, Long> {
    List<Project> findByOwnerEmail(String ownerEmail);
    // Hoac voi member check:
    @Query("SELECT p FROM Project p WHERE p.owner.email = :email OR :email MEMBER OF p.memberEmails")
    List<Project> findAccessibleByEmail(@Param("email") String email);
}

Rule of thumb: @PostFilter chỉ chấp nhận được với collection bound nhỏ cố định — dưới ~100 phần tử (top-10, recent-5, menu). Dataset không biết trước kích thước, hoặc hot path nhiều request/giây, thì bắt buộc filter tại DB: khi query có WHERE, DB engine dùng index và chỉ trả về row user có quyền — heap không bị spike.

Cost cụ thể: với 10 000 rows, mỗi Project object ~2 KB, @PostFilter tốn 10 000 × 2 KB = 20 MB heap chỉ để filter ra 5 row. Với 100 concurrent request cùng lúc, đó là 2 GB heap spike.

Kích thước collectionQuyết định
Dưới ~100 element cố định@PostFilter chấp nhận được
Vượt ~100 element, hoặc không biết trướcFilter tại DB — thêm WHERE vào query
Hot path nhiều request/giâyFilter tại DB bắt buộc — @PostFilter nhân overhead theo RPS

2.3 Khi nào @PostFilter vẫn hợp lý

@PostFilter có chỗ đứng khi:

  • Collection nhỏ cố định (top-10, recent-5, danh sách menu).
  • Logic filter quá phức tạp để biểu diễn trong SQL (rule nhiều branch, gọi bean Java).
  • Prototype, internal tool — không phải hot path production.
// OK: findTop10 tra ve toi da 10 phan tu — small, fixed bound
@PostFilter("filterObject.isPublic or filterObject.owner.email == authentication.name")
public List<Project> findTop10Recent() {
    return repo.findTop10ByOrderByCreatedAtDesc();
}

3. @PreFilter — lọc collection đầu vào

@PreFilter strip các element khỏi input collection trước khi method chạy. Spring eval filterObject cho mỗi element, remove phần tử trả về false.

@PreFilter("filterObject.amount <= 1000000 or hasRole('ADMIN')")
public void processTransfers(List<TransferRequest> transfers) {
    // transfers da bi strip truoc khi vao day
    transfers.forEach(this::doTransfer);
}

3.1 Vấn đề silent strip

@PreFilter remove element âm thầm — caller không biết một số phần tử bị loại. Nếu caller gửi 10 transfer và mong tất cả được xử lý nhưng 3 bị strip, caller vẫn nghĩ "10 thành công" trong khi thực tế chỉ có 7.

Pattern tốt hơn — throw rõ ràng thay vì silent strip:

// Thay vi @PreFilter silent strip:
@PreAuthorize("#transfers.?[amount > 1000000].size() == 0 or hasRole('ADMIN')")
public void processTransfers(List<TransferRequest> transfers) {
    transfers.forEach(this::doTransfer);
}

SpEL ?[...] filter collection inline — .size() == 0 kiểm tra không có element nào vi phạm rule. Nếu có phần tử vi phạm, Spring ném AccessDeniedException rõ ràng thay vì drop ngầm. Caller biết ngay request bị từ chối và lý do.

3.2 Bảng so sánh @PreFilter vs @PostFilter vs query at DB

@PreFilter@PostFilterQuery at DB
Thời điểm filterTrước method (strip input)Sau method (strip output)Trong SQL query
Load dữ liệu thừaKhông (input collection từ caller)Có (load all từ DB)Không
Silent behaviorStrip âm thầm — caller không biếtStrip âm thầmKhông trả row → empty/404
Phù hợp dataset lớnKhông phụ thuộc kích thước inputKhông — memory spike
RecommendHiếm — prefer throw exceptionSmall collection onlyDefault cho mọi size

4. AuthorizationManager — API Spring Security 6

Spring Security 6 refactor toàn bộ authorization pipeline thành một interface đơn giản:

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

T là context — MethodInvocation cho method security, RequestAuthorizationContext cho URL rule, hoặc custom type cho domain logic. Supplier<Authentication> cho phép defer fetch authentication chỉ khi cần (tối ưu cho rule permit anonymous — không cần load user nếu không check).

4.1 Vì sao thay AccessDecisionManager

Spring Security 5 dùng pattern:

AccessDecisionManager
├── AccessDecisionVoter[] — mỗi voter vote: GRANTED / DENIED / ABSTAIN
└── Strategy: AffirmativeBased / ConsensusBased / UnanimousBased

Nhược điểm: verbose (viết custom rule cần 1 voter class + register), strategy chỉ có 3 mode cố định (không compose linh hoạt), API kế thừa từ Spring Security 2.x thiếu generics.

Spring Security 6 đơn giản hoá bằng functional interface — compose qua allOf/anyOf:

import org.springframework.security.authorization.AuthorizationManagers;
import org.springframework.security.authorization.AuthorityAuthorizationManager;

// OR: ADMIN hoac owner deu duoc
AuthorizationManager<MethodInvocation> rule = AuthorizationManagers.anyOf(
    AuthorityAuthorizationManager.hasRole("ADMIN"),
    new ProjectOwnerAuthorizationManager(repo)
);

// AND: phai ca authenticated va co plan PAID
AuthorizationManager<MethodInvocation> paidRule = AuthorizationManagers.allOf(
    AuthenticatedAuthorizationManager.authenticated(),
    new PaidPlanAuthorizationManager(userRepo)
);

4.2 Custom AuthorizationManager implementation

public class ProjectOwnerAuthorizationManager
    implements AuthorizationManager<MethodInvocation> {

    private final ProjectRepository repo;

    public ProjectOwnerAuthorizationManager(ProjectRepository repo) {
        this.repo = repo;
    }

    @Override
    public AuthorizationDecision check(
        Supplier<Authentication> authSupplier,
        MethodInvocation invocation
    ) {
        Long projectId = (Long) invocation.getArguments()[0];
        String username = authSupplier.get().getName();
        boolean isOwner = repo.findById(projectId)
            .map(p -> p.getOwner().getEmail().equals(username))
            .orElse(false);
        return new AuthorizationDecision(isOwner);
    }
}

Sử dụng trong URL rule cùng AuthorizationManagers.anyOf:

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/projects/{projectId}/**").access(
        AuthorizationManagers.anyOf(
            AuthorityAuthorizationManager.hasRole("ADMIN"),
            new ProjectOwnerAuthorizationManager(projectRepo)
        )
    )
    .anyRequest().authenticated()
);

access(AuthorizationManager) là overload mới Spring Security 6 — thay thế access("SpEL string") deprecated.

5. Custom PermissionEvaluator — ownership check fine-grained

PermissionEvaluator là interface Spring Security cho authorization theo dạng (domain object, permission):

public interface PermissionEvaluator {
    // Truyen object truc tiep
    boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission);
    // Truyen ID + type string — load object trong evaluator
    boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission);
}

SpEL hỗ trợ function hasPermission(target, permission)hasPermission(targetId, targetType, permission) map vào interface này.

5.1 Vì sao cần PermissionEvaluator?

Khi app có nhiều domain object (Project, Task, Comment, Team) và mỗi object có nhiều permission level (READ/WRITE/DELETE/INVITE), khai báo rule trực tiếp trong SpEL sẽ rất dài và không test được:

// SpEL dài, khong test duoc rieng
@PreAuthorize("""
    hasRole('ADMIN') or
    (hasRole('USER') and #projectId != null and
     @projectRepo.findById(#projectId).map(p -> p.getOwner().getEmail()).orElse('').equals(authentication.name))
    """)
public void update(Long projectId, ProjectUpdate update) { ... }

PermissionEvaluator tập trung logic vào một class Java test được:

// SpEL ngan, ro rang
@PreAuthorize("hasPermission(#projectId, 'Project', 'WRITE')")
public void update(Long projectId, ProjectUpdate update) { ... }

5.2 Implement PermissionEvaluator

@Component
@RequiredArgsConstructor
public class AppPermissionEvaluator implements PermissionEvaluator {

    private final ProjectRepository projectRepo;

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

    @Override
    public boolean hasPermission(
        Authentication auth,
        Serializable targetId,
        String targetType,
        Object permission
    ) {
        return switch (targetType) {
            case "Project" -> projectRepo.findById((Long) targetId)
                .map(p -> checkProject(auth, p, (String) permission))
                .orElse(false);
            default -> false;
        };
    }

    private boolean checkProject(Authentication auth, Project p, String permission) {
        boolean isAdmin = hasRole(auth, "ADMIN");
        boolean isOwner = p.getOwner().getEmail().equals(auth.getName());
        boolean isMember = p.getMembers().stream()
            .anyMatch(m -> m.getEmail().equals(auth.getName()));

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

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

5.3 Register evaluator vào expression handler

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {

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

Không có bean này, hasPermission(...) trong SpEL luôn trả về false — silent failure nguy hiểm.

5.4 Sử dụng trong annotation

@Service
@RequiredArgsConstructor
public class ProjectService {

    private final ProjectRepository repo;

    // Truyen ID — evaluator tu load object
    @PreAuthorize("hasPermission(#projectId, 'Project', 'READ')")
    public Project findById(Long projectId) {
        return repo.findById(projectId).orElseThrow();
    }

    // Truyen object truc tiep — bo qua 1 DB query trong evaluator
    @PreAuthorize("hasPermission(#project, 'WRITE')")
    public void update(Project project, ProjectUpdate update) { ... }

    @PreAuthorize("hasPermission(#projectId, 'Project', 'DELETE')")
    public void delete(Long projectId) {
        repo.deleteById(projectId);
    }
}

5.5 Pitfall — hasPermission return false khi quên register

Nếu thiếu MethodSecurityExpressionHandler bean (section 5.3), Spring dùng DenyAllPermissionEvaluator mặc định → mọi hasPermission(...) đều trả false → mọi request bị 403 dù user có quyền.

Nguy hiểm ở chỗ có ba tầng cấu hình cho ra cùng một triệu chứng, và không tầng nào ghi log. Loại chúng theo thứ tự trước khi mở evaluator ra đọc lại logic:

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

Kiểm tra bằng test:

@Test
@WithMockUser(username = "[email protected]", roles = "USER")
void findById_owner_shouldSucceed() {
    Long id = seedProject("[email protected]");
    // Neu 403 o day: kiem tra MethodSecurityExpressionHandler bean da duoc register chua
    assertThatCode(() -> projectService.findById(id)).doesNotThrowAnyException();
}

6. @Secured@RolesAllowed — annotation legacy

Trước @PreAuthorize, Spring có 2 annotation đơn giản hơn. Học để nhận ra khi đọc code cũ — không khuyến nghị viết mới.

6.1 @Secured — Spring 1.x era

// Bat: @EnableMethodSecurity(securedEnabled = true)
@Secured("ROLE_ADMIN")
public void deleteAll() { ... }

@Secured({"ROLE_USER", "ROLE_ADMIN"})
public Project create(CreateProjectRequest req) { ... }

Chỉ nhận role string (phải có prefix ROLE_). Không hỗ trợ SpEL, không truy cập method args, không call bean.

6.2 @RolesAllowed — JSR-250 chuẩn Jakarta

// Bat: @EnableMethodSecurity(jsr250Enabled = true)
@RolesAllowed("ADMIN")
public void deleteAll() { ... }

@RolesAllowed({"USER", "ADMIN"})
public Project create(...) { ... }

@PermitAll
public List<Project> listPublic() { ... }

@DenyAll
public void deprecatedEndpoint() { ... }

Là chuẩn Jakarta EE — không cần prefix ROLE_. Portable giữa Spring và CDI container khác. Cũng không có SpEL.

6.3 Khi nào chọn cái nào

AnnotationKhi nên chọn
@PreAuthorizeDefault — SpEL, method args, custom bean
@SecuredCode legacy Spring 2-3, không cần SpEL
@RolesAllowedCần portable giữa Spring và CDI/Jakarta

99% project mới dùng @PreAuthorize độc quyền. Hai annotation kia chỉ cần nhận ra khi đọc code legacy.

Cơ chế bên dưới — vì sao @PostFilter bad cho large dataset

Bản chất vấn đề nằm ở vị trí của PostFilterAuthorizationMethodInterceptor trong luồng AOP. Interceptor chạy sau khi JpaRepository.findAll() trả về — tức là sau khi Hibernate đã sinh SQL, DB đã execute, kết quả đã được map thành object và đưa lên heap Java.

Đây là lý do @PostFilter tệ về performance khi dataset lớn — nó không thể "can thiệp" vào SQL generation. Interceptor chỉ chạy sau khi result set đã được materialise hoàn toàn trên JVM heap. Sơ đồ hai cột ở section 2.1 vẽ đúng thứ tự đó: hộp interceptor nằm dưới hộp hydrate, nên tại thời điểm nó chạy thì câu SQL đã đóng lại từ lâu.

Ngược lại, filter tại DB bằng Spring Data query method (findByOwnerEmail) hoặc @Query với WHERE thêm điều kiện vào SQL trước khi DB execute — DB engine dùng index để trả đúng row, không load thừa.

Pitfall: @PostFilter trên endpoint pagination

@PostFilter kết hợp với Pageable là double anti-pattern: findAll(Pageable) load N row theo page → filter in-memory → page trả về ít hơn N row. Caller thấy "page 1 có 3 item" dù page size=20, không hiểu vì sao. Fix dứt khoát: đưa điều kiện ownership vào query, không dùng @PostFilter trên paginated endpoint.

Liên hệ các bài khác

  • AOP proxy và @PreAuthorize: bài này giải thích vì sao annotation hoạt động qua proxy, self-call bypass, CGLIB vs JDK proxy — đọc trước để hiểu cơ sở của @PreFilter/@PostFilter cùng pipeline AOP interceptor.
  • CORS — Same-Origin và preflight: bài tiếp theo — bảo vệ app khỏi cross-origin request không hợp lệ; CORS config thường đi kèm với method security trong Spring Security filter chain.
  • @PreAuthorize & AOP proxy: cùng pipeline interceptor với @PreFilter/@PostFilter; bài đó mổ cơ chế proxy còn bài này đào sâu performance trade-off khi filter collection và ownership pattern.

Tóm tắt

  • @PostFilter load tất cả element rồi filter in-memory — chỉ phù hợp collection dưới ~100 phần tử. Dataset lớn nên filter tại DB (findByOwnerEmail, @Query với WHERE).
  • @PreFilter strip element đầu vào âm thầm — caller không biết item bị loại. Prefer @PreAuthorize với SpEL ?[...] để throw exception rõ ràng thay vì silent drop.
  • AuthorizationManager<T> (Spring Security 6) thay AccessDecisionManager (SS5) — functional interface, generic, compose qua allOf/anyOf, lazy authentication.
  • PermissionEvaluator tập trung logic ownership vào class Java test được — hasPermission(#id, 'Project', 'WRITE') trong SpEL gọi evaluator theo (targetId, targetType, permission).
  • Quên register MethodSecurityExpressionHandler bean → hasPermission luôn false → mọi request bị 403 (silent failure).
  • @Secured / @RolesAllowed là legacy — không SpEL, không method args. Dùng @PreAuthorize cho code mới.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Giải thích vì sao @PostFilter("filterObject.owner.email == authentication.name") gây vấn đề performance khi table projects có 50 000 row. Vấn đề nằm ở đâu trong luồng xử lý?
  2. Q2
    Service sau đây có @PreFilter. User gửi 5 transfer (3 dưới 1 triệu, 2 trên 5 triệu). Điều gì xảy ra? Tại sao đây là bad practice và bạn sẽ fix thế nào?
  3. Q3
    Spring Security 6 thay AccessDecisionManager bằng AuthorizationManager. Nêu 3 lý do kỹ thuật và minh hoạ bằng code compose rule "ADMIN hoặc owner project".
  4. Q4
    Bạn implement PermissionEvaluator và register vào Spring context nhưng mọi hasPermission(...) trong @PreAuthorize đều trả false dù user có quyền. Debug thế nào?
  5. Q5
    So sánh 3 cách implement "user chỉ thấy project họ có quyền": @PostFilter, query filter tại DB, và custom PermissionEvaluator với @PreAuthorize. Cho biết khi nào chọn mỗi cách.

Bài tiếp theo: CORS — Same-Origin & preflight

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.

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

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

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

CORS — Same-Origin Policy, preflight, và Spring config