1. mở đầu

Trong rất nhiều hệ thống nghiệp vụ, lập trình viên thường bắt đầu từ một nhu cầu rất đơn giản. Người dùng cần xem đơn hàng, xem hồ sơ, xem hợp đồng, xem tài liệu. Vì vậy, hệ thống tạo ra các endpoint như /api/orders/12345, /api/users/101, hoặc /api/documents/999. Ở bề mặt, cách làm này hoàn toàn tự nhiên. Mỗi tài nguyên có một định danh, client gửi định danh đó lên server, server lấy dữ liệu từ database rồi trả về.

Vấn đề chỉ xuất hiện khi ta đặt thêm một câu hỏi mà nhiều hệ thống quên hỏi. Người đang gửi request này có thực sự được quyền truy cập vào tài nguyên đó hay không. Chính tại điểm này, lỗ hổng Insecure Direct Object Reference, hay IDOR, bắt đầu hình thành. 

IDOR không phải là lỗi hiếm. Nó là một biểu hiện rất điển hình của Broken Access Control. Hệ thống không sai ở chỗ truy xuất được object. Hệ thống sai ở chỗ truy xuất object chỉ dựa trên định danh, nhưng không ràng buộc việc truy xuất đó với quyền thực tế của caller. Nói cách khác, hệ thống biết tìm tài nguyên, nhưng không biết phân biệt ai được động vào tài nguyên đó.

Vì vậy, nếu muốn hiểu và chặn IDOR cho đúng, ta không thể chỉ nói về URL hay tham số request. Ta phải đi từ gốc rễ hơn. Gốc rễ đó là mô hình phân quyền, là cách dữ liệu được lưu, là nơi quyền được kiểm tra, và là cách toàn bộ ứng dụng tổ chức logic bảo vệ quanh tài nguyên.

Bài viết này đi theo đúng mạch đó. Trước hết, ta nhìn thẳng vào một ví dụ IDOR rất điển hình. Sau đó, ta phân loại các biến thể của IDOR để thấy vấn đề không chỉ nằm ở path parameter. Từ đó, ta chuyển sang phần nền tảng là các mô hình Access Control. Khi nền tảng đã rõ, ta mới thấy vì sao phải thiết kế database theo ownership, vì sao phải có ACL, vì sao phải có kế thừa quyền, vì sao Spring Security mặc định vẫn chưa đủ, và vì sao hệ thống nhiều tenant cần thêm hàng rào ở cả tầng repository và database. Sau cùng, ta ghép mọi thứ lại trong một case study đa tổ chức để kiểm tra xem kiến trúc này có thực sự đứng vững trước các tình huống khó hay không. 

2. nhìn vào một lỗ hổng idor điển hình

Hãy xét đoạn mã sau:

</> Java
@GetMapping("/api/documents/{documentId}")
public ResponseEntity<DocumentDto> getDocument(@PathVariable Long documentId) {
    Document doc = documentRepository.findById(documentId)
                     .orElseThrow(() -> new NotFoundException("Khong tim thay"));
    return ResponseEntity.ok(mapper.toDto(doc));
}

Mới nhìn qua ta sẽ thấy đoạn mã này rất sạch. Nó ngắn, rõ, đúng chức năng. Nhưng chính sự ngắn gọn đó lại làm lộ vấn đề. Endpoint chỉ thực hiện hai việc. Tìm tài liệu theo documentId. Nếu có thì trả về. Hoàn toàn không có dòng nào kiểm tra quan hệ giữa người gọi và tài liệu được yêu cầu.

Hệ quả là gì. Chỉ cần một người dùng đã đăng nhập, họ có thể bắt đầu thử các giá trị documentId khác nhau. Nếu tài liệu có tồn tại, server sẽ trả về. Như vậy, một người không cần phá xác thực (authentication), không cần khai thác SQL injection, không cần RCE, vẫn có thể đọc dữ liệu không thuộc về mình chỉ bằng cách thay đổi một con số trong URL. Đây chính là bản chất của IDOR. 

Điểm đáng lưu ý là rất nhiều đội ngũ nghĩ rằng họ đã an toàn vì frontend không hiển thị đường dẫn tới tài liệu của người khác. Nhưng frontend không phải là nơi ra quyết định cuối cùng. Bất kỳ ai có công cụ như curl, Postman hoặc Burp Suite đều có thể bỏ qua giao diện và gọi API trực tiếp. Vì vậy, mọi logic phân quyền chỉ nằm ở frontend đều chỉ là cảm giác an toàn chứ không phải an toàn thật. 

Từ ví dụ nhỏ này, ta rút ra một nguyên tắc rất quan trọng. Trong ứng dụng hiện đại, bảo mật không bắt đầu ở màn hình. Bảo mật bắt đầu ở server side authorization.

3. idor không chỉ có một hình dạng

Sau khi đã thấy một ví dụ trực diện, ta cần mở rộng góc nhìn. Nhiều đội ngũ hiểu IDOR quá hẹp. Họ chỉ liên tưởng đến việc đổi 123 thành 124 trong URL. Thực tế, đó chỉ là biến thể dễ nhìn thấy nhất. IDOR có thể xuất hiện ở bất kỳ chỗ nào mà client chỉ định một object, còn server lại quên xác minh rằng caller có quyền trên object đó.

3.1. idor trực tiếp qua path parameter

Đây là dạng quen thuộc nhất. Path chứa ID, server đọc ID, truy vấn object, rồi trả về. Nếu không kiểm tra quyền, người dùng chỉ cần tăng giảm ID để lần ra dữ liệu của người khác.

</> http
GET /api/users/101
GET /api/users/102
GET /api/users/103

Vấn đề của dạng này nằm ở chỗ endpoint đang trả lời sai câu hỏi. Nó đang trả lời câu hỏi object có tồn tại hay không, trong khi điều phải trả lời là caller có được phép truy cập object này hay không.

3.2. idor gián tiếp qua reference id

Không phải lúc nào ID cũng nằm trong path. Nhiều khi ID nằm trong body request, query parameter, hoặc object lồng nhau. Chính vì ít lộ hơn nên developer thường bỏ sót.

</> http
POST /api/profile/avatar
Content-Type: multipart/form-data
{
 "targetUserId": 102,
  "file": <binary data>
}

Nếu server chấp nhận targetUserId từ client mà không so với principal hiện tại hoặc không kiểm tra quyền thực sự, người dùng có thể sửa ảnh đại diện cho người khác. Đây vẫn là IDOR, chỉ khác là object reference được truyền gián tiếp. 

3.3. idor kết hợp path traversal hoặc cross tenant path

Ở các hệ thống quản lý file, tài liệu hoặc báo cáo, object đôi khi được truy cập thông qua đường dẫn logic.

</> http
GET /api/files/download?path=invoices/2024/invoice-001.pdf

Nếu server ghép đường dẫn không chặt chẽ, kẻ tấn công có thể thử đi sang thư mục của tenant khác hoặc user khác.

</> http
GET /api/files/download?path=../../tenant_99/user_55/contracts/secret.pdf

Điểm chung với IDOR vẫn giữ nguyên. Client đã chỉ định một đối tượng không thuộc phạm vi của mình, còn server lại không ràng buộc yêu cầu đó với quan hệ sở hữu hoặc biên tenant. 

3.4. idor qua tham số ẩn

Có những API gửi thêm các trường mà frontend điền tự động như userId, ownerId, roleId, accountId. Người dùng bình thường không nhìn thấy, nhưng proxy sẽ nhìn thấy và sửa được.

</> http
POST /api/orders
{
  "items": [{"productId": 5, "qty": 2}],
  "userId": 101
}

Nếu attacker đổi userId thành 999 mà server vẫn tin, hệ thống sẽ thực hiện hành động thay cho người khác. Đây là một lỗi rất nguy hiểm vì ở nhiều code review, mọi người quá tập trung vào giao diện nên quên rằng payload thực tế luôn có thể bị sửa trước khi đến server.

3.5. idor trong batch operation

Đây là chỗ rất nhiều hệ thống vấp phải. Khi xử lý hàng loạt, lập trình viên thường chỉ xác thực request ở mức toàn cục rồi thao tác trực tiếp trên danh sách ID.

</> http
DELETE /api/messages/bulk
{
 "messageIds": [1001, 5500, 8899, 12345]
}

Sai lầm ở đây là kiểm tra một lần cho request, nhưng lại không kiểm tra từng object trong mảng. Trong bài toán phân quyền, mỗi phần tử là một quyết định riêng. Không có chuyện một request hợp lệ thì mọi phần tử bên trong tự động hợp lệ. 

3.6. function level idor

Có những trường hợp caller không đọc dữ liệu trái phép mà thực hiện hành động trái phép.

</> http
POST /api/orders/7788/confirm
POST /api/applications/3344/reject
POST /api/invoices/9901/mark-as-paid

Nếu server chỉ kiểm tra người dùng có role phù hợp, nhưng không kiểm tra object đó có thuộc phạm vi quyền của người dùng hay không, hệ thống vẫn bị IDOR. Ở đây, vấn đề đã chuyển từ data exposure sang unauthorized action, nhưng bản chất vẫn không đổi. Server không kiểm tra quan hệ giữa caller và resource.

Đến đây, ta có thể rút ra kết luận chung. IDOR không phải lỗi của kiểu truyền tham số nào. Nó là lỗi của tư duy. Cụ thể hơn, đó là tư duy tin vào object reference do client cung cấp mà không ép object đó đi qua một cơ chế authorization theo tài nguyên.

4. muốn chặn idor tận gốc thì phải quay lại access control

Sau khi nhìn đủ các biến thể, ta bắt đầu thấy một điều. Nếu chỉ vá từng endpoint, từng body field, từng query parameter, ta sẽ luôn đi sau vấn đề. Cách xử lý căn cơ hơn là quay về câu hỏi nền tảng. Hệ thống của ta đang dùng mô hình phân quyền nào, và mô hình đó có thực sự trả lời được câu hỏi ai được quyền trên object cụ thể nào hay không.

4.1. discretionary access control

Discretionary Access Control, hay DAC, là mô hình rất gần với trực giác con người. Chủ sở hữu của tài nguyên có quyền quyết định ai được truy cập. File system truyền thống, ACL trên file, hoặc cơ chế chia sẻ tài liệu đều phản ánh tinh thần này.

Ưu điểm của DAC là tự nhiên và dễ hiểu. Nhược điểm của nó là khó mở rộng ở quy mô lớn, vì số quan hệ giữa user và object tăng rất nhanh. Nếu mọi quyền đều phải quản lý trực tiếp theo từng object, hệ thống sẽ sớm trở nên phức tạp.

4.2. role based access control

Role Based Access Control, hay RBAC, xuất hiện để giải quyết bài toán mở rộng. Thay vì cấp quyền cho từng user trên từng object, hệ thống gán role cho user và gán quyền cho role. Ví dụ, ADMIN có thể tạo user, EDITOR có thể sửa tài liệu, VIEWER có thể xem báo cáo.

RBAC rất hiệu quả ở tầng chức năng. Nó trả lời tốt câu hỏi ai được gọi endpoint nào, ai được dùng module nào, ai được thực hiện loại hành động nào. Đây là lý do Spring Security hỗ trợ RBAC rất tốt qua hasRole(...). 

Nhưng RBAC có một điểm mù rất lớn. Nó không biết object cụ thể thuộc về ai. Một EDITOR có thể được phép gọi endpoint cập nhật tài liệu, nhưng RBAC không tự biết tài liệu số 999 có thuộc EDITOR đó hay không. Chính khoảng trống này là nơi IDOR sinh ra. Nói cách khác, RBAC giỏi kiểm soát chức năng, nhưng không đủ để kiểm soát đối tượng cụ thể.

4.3. attribute based access control

Attribute Based Access Control, hay ABAC, mở rộng thêm chiều sâu. Thay vì chỉ nhìn role, hệ thống nhìn cả thuộc tính của subject, resource và environment. Ví dụ, người dùng thuộc phòng tài chính, tài liệu thuộc tenant A, hành động diễn ra trong giờ làm việc, dữ liệu có classification nội bộ.

ABAC diễn tả được logic linh hoạt hơn nhiều. Tuy nhiên, cái giá phải trả là độ phức tạp trong viết policy, vận hành và debug cũng tăng lên. Đây là mô hình mạnh, nhưng không phải nơi nào cũng cần đẩy đến cực hạn. 

4.4. relationship based access control

Relationship Based Access Control, hay ReBAC, là mô hình phù hợp nhất khi bài toán trọng tâm là quan hệ giữa người dùng và tài nguyên. Ở đây, hệ thống được nhìn như một đồ thị. User, group, folder, document, project đều là node. Các quan hệ như owner, member, viewer, editor là edge.

Khi cần quyết định quyền, hệ thống không chỉ hỏi user có role gì. Hệ thống hỏi user này có đường quan hệ hợp lệ nào dẫn tới resource này hay không. Ví dụ, user là owner của document. Hoặc user thuộc group được chia sẻ document. Hoặc user có quyền trên folder cha của document. Tư duy này gần với bản chất của IDOR hơn bất kỳ mô hình nào trước đó, vì IDOR chính là lỗi trong việc không xét quan hệ giữa caller và object. 

5. resource based access control là cách đem rebac vào spring boot

Khi đi vào triển khai, ta thường không dựng cả một graph engine chuyên biệt. Trong ứng dụng Spring Boot thông thường, điều thực tế hơn là dùng một mô hình mà bài gốc gọi là Resource Based Access Control. Cần hiểu đúng chỗ này.

Resource Based Access Control không phải là một trường phái hoàn toàn tách rời. Nó là cách hiện thực hóa tinh thần của ReBAC trong ứng dụng web thực tế, đồng thời kết hợp thêm yếu tố của DAC và ABAC. 

Nói ngắn gọn, một hệ thống Resource Based Access Control tốt cần trả lời được ít nhất bốn câu hỏi mỗi khi kiểm tra quyền:

  1. Người gọi có phải chủ sở hữu của tài nguyên không. 
  2. Người gọi có được cấp quyền tường minh trên tài nguyên đó không. 
  3. Người gọi có thuộc tổ chức, nhóm, phòng ban hoặc tenant phù hợp hay không. 
  4. Quyền có thể được kế thừa từ tài nguyên cha hay không. 

Khi ghép với RBAC, hệ thống sẽ có hai lớp rõ ràng. Lớp ngoài dùng RBAC để bảo vệ chức năng. Lớp trong dùng Resource Based Access Control để bảo vệ từng object cụ thể. Chỉ khi cả hai lớp này cùng tồn tại, ta mới thật sự chặn được IDOR một cách toàn diện.

6. tại sao database phải được thiết kế lại nếu muốn chống idor đúng nghĩa

Đến đây, mạch logic đã rõ. Nếu authorization phải xét theo tài nguyên, thì database cũng phải lưu được những dữ kiện cần thiết để việc xét quyền có cơ sở. Đây là chỗ nhiều hệ thống gặp vấn đề. Họ muốn kiểm tra ownership, muốn kiểm tra tenant, muốn kiểm tra kế thừa, nhưng schema lại không lưu đủ thông tin.

6.1. mỗi tài nguyên phải mang dấu vết sở hữu

Một bảng tài nguyên tốt nên có tối thiểu các trường sau.

  1. owner_id để biết ai là chủ sở hữu trực tiếp. 
  2. tenant_id để biết tài nguyên thuộc biên tổ chức nào. 
  3. visibility để biểu diễn phạm vi hiển thị cơ bản như private, tenant hoặc public. 
  4. created_by để phục vụ audit. 
  5. uuid để làm định danh công khai thay cho ID nội bộ. 

Ví dụ:

</> SQL
CREATE TABLE documents (
    id               BIGSERIAL PRIMARY KEY,
    uuid             UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    title            VARCHAR(500) NOT NULL,
    content          TEXT,
    owner_id         BIGINT NOT NULL REFERENCES users(id),
    tenant_id        BIGINT NOT NULL REFERENCES tenants(id),
    parent_folder_id BIGINT REFERENCES folders(id),
    visibility       VARCHAR(20) NOT NULL DEFAULT 'PRIVATE',
    created_by       BIGINT NOT NULL REFERENCES users(id),
    created_at       TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at       TIMESTAMP NOT NULL DEFAULT NOW(),
    deleted_at       TIMESTAMP,
    CONSTRAINT chk_visibility CHECK (visibility IN ('PRIVATE','TENANT','PUBLIC'))
);

Thiếu owner_id, hệ thống không thể trả lời ai là chủ. Thiếu tenant_id, hệ thống không thể ngăn cross tenant access một cách chặt chẽ. Thiếu parent_folder_id, hệ thống không thể làm kế thừa quyền. Như vậy, chống IDOR không chỉ là bài toán viết if ở service. Nó bắt đầu từ thiết kế dữ liệu.

6.2. cần acl để cấp quyền tường minh

Không phải lúc nào ownership cũng đủ. Trong thực tế, tài liệu phải được chia sẻ, hợp đồng phải được review, thư mục dự án phải được mở quyền tạm thời cho bên thứ ba. Vì vậy, hệ thống cần một bảng ACL, hay Access Control List, để lưu các cấp quyền tường minh.

</> SQL
CREATE TABLE resource_permissions (
    id              BIGSERIAL PRIMARY KEY,
    resource_type   VARCHAR(50) NOT NULL,
    resource_id     BIGINT NOT NULL,
    principal_type  VARCHAR(20) NOT NULL,
    principal_id    BIGINT NOT NULL,
    permission      VARCHAR(30) NOT NULL,
    granted_by      BIGINT NOT NULL REFERENCES users(id),
    granted_at      TIMESTAMP NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMP,
    revoked_at      TIMESTAMP,
    revoke_reason   VARCHAR(200),
    UNIQUE(resource_type, resource_id, principal_type, principal_id, permission)
);

Bảng này cho phép biểu diễn rất nhiều tình huống thực tế. Quyền đọc cho một user cụ thể. Quyền sửa cho một group. Quyền tạm thời cho auditor trong 30 ngày. Khi ACL được lưu rõ trong dữ liệu, authorization không còn là suy đoán. Nó trở thành phép kiểm tra có chứng cứ.

6.3. nếu có cấu trúc cây thì phải nghĩ đến kế thừa quyền

Nhiều tài nguyên không đứng độc lập. Chúng nằm trong thư mục, dự án, workspace hoặc tenant. Khi quyền được cấp ở node cha, hệ thống thường cần kế thừa xuống node con. Nếu không thiết kế phần này từ đầu, logic authorization sẽ nhanh chóng bị chắp vá.

Bài gốc đề xuất dùng materialized path với LTREE của PostgreSQL. Đây là một lựa chọn thực dụng vì cho phép truy vấn ancestor hiệu quả. Khi người dùng có quyền trên folder cha, hệ thống chỉ cần lấy tập ancestor rồi kiểm tra ACL trên tập đó. 

7. row level security là lưới an toàn phía dưới

Sau khi có schema đúng, ta vẫn chưa nên dừng lại ở tầng service. Lý do là logic ứng dụng luôn có thể có lỗi. Một developer có thể quên gọi checker ở một method nào đó. Một endpoint mới có thể được thêm vào mà chưa đi qua đầy đủ review. Đây là lúc Row Level Security, hay RLS, trở nên quan trọng về mặt kiến trúc. 

Ý tưởng của RLS rất đơn giản. Dù ai truy vấn cùng một bảng, mỗi người chỉ nên nhìn thấy các dòng mà họ có quyền. PostgreSQL hỗ trợ RLS ở engine. Trong nhiều ứng dụng Spring Boot, đội ngũ chọn cách thực dụng hơn là ép tenantId, ownerId hoặc điều kiện tương đương vào các truy vấn repository. Dù triển khai ở engine hay ORM, mục tiêu cốt lõi vẫn như nhau. Không để câu lệnh truy vấn trả về dữ liệu của người khác ngay từ tầng thấp nhất có thể. 

Nhìn ở góc độ defense in depth, Resource Based Access Control ở service là lớp kiểm tra chủ động. RLS ở data layer là lớp chặn thụ động. Hai lớp này không thay thế nhau. Chúng bổ trợ cho nhau.

8. đem mô hình đó vào spring boot như thế nào

Sau khi phần lý thuyết và dữ liệu đã rõ, ta mới đi vào phần mà nhiều người quan tâm nhất, tức là triển khai trong Spring Boot. Điểm quan trọng ở đây là đừng viết authorization theo kiểu rải rác. Nếu mỗi service tự nghĩ ra một kiểu kiểm tra khác nhau, hệ thống sẽ rất nhanh mất tính nhất quán. Bài toán cần một điểm điều phối trung tâm.

8.1. bật method security

Đầu tiên, cần bật Method Security để service method có thể sử dụng @PreAuthorize.

</> Java
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
            JwtAuthFilter jwtAuthFilter) throws Exception {
        http
            .csrf(csrf -> csrf.csrfTokenRepository(
               CookieCsrfTokenRepository.withHttpOnlyFalse()))
            .sessionManagement(sm -> sm.sessionCreationPolicy(
               SessionCreationPolicy.STATELESS))
           .authorizeHttpRequests(auth -> auth
               .requestMatchers("/api/public/**").permitAll()
               .anyRequest().authenticated()
            )
           .addFilterBefore(jwtAuthFilter,
               UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

Cấu hình này mở đường cho một kiểu biểu thức mạnh hơn hasRole(...). Thay vì hỏi user có role gì, ta có thể hỏi user có quyền gì trên resource nào.

8.2. dùng custom permission evaluator làm điểm điều phối

Thành phần quan trọng nhất là PermissionEvaluator tùy chỉnh. Đây là nơi gom toàn bộ logic authorization theo tài nguyên.

</> Java
public interface ResourceAuthorizationChecker {
    String getResourceType();
    boolean check(Authentication auth, Object target, String permission);
    boolean checkById(Authentication auth, Long targetId, String permission);
}
</> Java
@Component
public class ResourcePermissionEvaluator implements PermissionEvaluator {

    private final Map<String, ResourceAuthorizationChecker> checkers;

    public ResourcePermissionEvaluator(
           List<ResourceAuthorizationChecker> checkerList) {
        this.checkers = checkerList.stream().collect(Collectors.toMap(
            c -> c.getResourceType().toUpperCase(),
           Function.identity()
        ));
    }

    @Override
    public boolean hasPermission(Authentication auth,
            Object targetDomainObject, Object permission) {
        if (targetDomainObject == null) return false;
        String type = targetDomainObject.getClass()
                         .getSimpleName().toUpperCase();
       ResourceAuthorizationChecker checker = checkers.get(type);
        if (checker == null) return false;
        return checker.check(auth, targetDomainObject, permission.toString());
    }

    @Override
    public boolean hasPermission(Authentication auth,
           Serializable targetId, String targetType, Object permission) {
       ResourceAuthorizationChecker checker =
           checkers.get(targetType.toUpperCase());
        if (checker == null) return false;
        return checker.checkById(auth, (Long) targetId, permission.toString());
    }
}

Ý nghĩa kiến trúc ở đây rất rõ. Authorization không còn bị nhúng trực tiếp vào từng service method dưới dạng nhiều câu if khác nhau. Mỗi loại tài nguyên có một checker riêng, còn evaluator làm nhiệm vụ điều phối. Nhờ đó, hệ thống dễ mở rộng, dễ review, dễ test, và quan trọng hơn là giữ được một chuẩn chung cho cách kiểm tra quyền.

8.3. document authorization checker là nơi quyền thật sự được quyết định

Với tài liệu, checker phải đi qua nhiều tầng kiểm tra.

  1. Chặn cross tenant trước. 
  2. Nếu là owner thì cho phép. 
  3. Nếu visibility là tenant và action là read thì cho phép. 
  4. Nếu có ACL cấp trực tiếp cho user thì cho phép. 
  5. Nếu có ACL qua group thì cho phép. 
  6. Nếu tài liệu nằm trong folder và folder cha có quyền kế thừa thì cho phép. 

Chính thứ tự này phản ánh tư duy authorization trưởng thành. Ta đi từ các hàng rào cứng nhất sang các quan hệ mềm hơn, và chỉ cho phép khi có một căn cứ rõ ràng. Đây là fail secure theo đúng nghĩa. Nếu không chứng minh được quyền, mặc định là từ chối. 

8.4. tầng service lúc này trở nên rất gọn

Khi checker đã làm đúng việc của nó, service method chỉ còn nhiệm vụ gọi nghiệp vụ.

</> Java
@Service
@RequiredArgsConstructor
public class DocumentService {

    private final DocumentRepository documentRepo;
    private final ResourcePermissionRepository aclRepo;
    private final SecurityContextHelper secCtx;

   @PreAuthorize("hasPermission(#documentId, 'DOCUMENT', 'READ')")
    public DocumentDto getDocument(Long documentId) {
        return documentRepo.findById(documentId)
           .map(mapper::toDto)
           .orElseThrow(() ->
                new ResourceNotFoundException("Document", documentId));
    }

   @PreAuthorize("hasPermission(#documentId, 'DOCUMENT', 'WRITE')")
    public DocumentDto updateDocument(Long documentId,
           UpdateDocumentRequest req) {
        Document doc = documentRepo.findById(documentId)
           .orElseThrow(() ->
                new ResourceNotFoundException("Document", documentId));
       doc.setTitle(req.getTitle());
       doc.setContent(req.getContent());
        return mapper.toDto(documentRepo.save(doc));
    }

   @PreAuthorize("hasPermission(#documentId, 'DOCUMENT', 'DELETE')")
    public void deleteDocument(Long documentId) {
       documentRepo.softDeleteById(documentId, Instant.now());
    }
}

Điều đáng giá ở đây không chỉ là code đẹp hơn. Điều đáng giá là authorization đã được chuẩn hóa. Mọi service cùng nói một ngôn ngữ. Đó là hasPermission(resource, action). Khi review mã nguồn, đội bảo mật cũng dễ nhìn hơn rất nhiều.

8.5. repository vẫn phải tự bảo vệ

Một sai lầm phổ biến là nghĩ rằng đã có @PreAuthorize thì repository có thể query tự do. Không nên như vậy. Ngay cả khi service kiểm tra đúng, repository vẫn nên gắn điều kiện tenant hoặc ownership vào truy vấn nếu có thể.

</> Java
Optional<Document> findByIdAndTenantIdAndDeletedAtIsNull(
    Long id, Long tenantId);

Điều này biến data access thành một hàng rào nữa. Nếu một chỗ nào đó quên gọi authorization checker, khả năng lộ dữ liệu vẫn giảm đi đáng kể vì repository không truy xuất xuyên tenant một cách tự do. Đây chính là defense in depth trong thực hành.

9. case study đa tổ chức cho thấy vì sao kiến trúc này cần thiết

Lý thuyết chỉ thật sự có giá trị khi đi qua một bài toán đủ khó. Case study về hệ thống quản lý hợp đồng đa tổ chức là một ví dụ rất phù hợp vì nó chứa nhiều lớp quyền khác nhau cùng lúc. Có tenant admin, có manager theo phòng ban, có employee thường, có legal reviewer, có external auditor với quyền có thời hạn, và còn có cả kế thừa theo cây thư mục.

Nếu một hệ thống như vậy chỉ dùng RBAC thuần túy, gần như chắc chắn sẽ phát sinh IDOR. Bởi vì role chỉ nói người dùng là manager hay auditor. Role không tự trả lời được hợp đồng nào thuộc phòng ban nào, thư mục nào kế thừa xuống đâu, auditor được đọc đến ngày nào, tenant admin của công ty A có thể thấy dữ liệu công ty B hay không.

Chính vì vậy, ContractAuthorizationChecker trong bài gốc mới được xây theo nhiều tầng.

  1. Tìm hợp đồng kèm tenantId để chặn cross tenant ngay từ đầu. 
  2. Nếu là TENANT_ADMIN trong đúng tenant thì cho phép. 
  3. Nếu là owner thì cho phép. 
  4. Nếu là CONTRACT_MANAGER và hợp đồng cùng phòng ban hoặc phòng ban con thì cho phép đọc. 
  5. Nếu có ACL cấp cho user hoặc group thì cho phép. 
  6. Nếu hợp đồng nằm trong folder dự án có quyền kế thừa thì cho phép. 

Nhìn vào chuỗi quyết định này, ta thấy một điều rất rõ. Authorization không còn là một câu lệnh đơn lẻ. Nó là một tiến trình suy luận có cấu trúc, và cấu trúc đó bám sát nghiệp vụ thực tế của hệ thống. Đây chính là mức trưởng thành mà các hệ thống có dữ liệu nhạy cảm cần đạt tới. 

9.1. external auditor là ví dụ đẹp của quyền có thời hạn

Một tình huống rất thực tế là auditor bên ngoài chỉ được đọc hồ sơ dự án trong một khoảng thời gian xác định. Đây là chỗ mô hình ACL với expires_at phát huy tác dụng.

</> Java
@Service
public class AuditAccessService {

    private final ResourcePermissionRepository aclRepo;

   @PreAuthorize("hasRole('TENANT_ADMIN')")
    @Transactional
    public void grantAuditorAccess(GrantAuditorAccessRequest req) {
       ResourcePermission perm = new ResourcePermission();
       perm.setResourceType("CONTRACT_FOLDER");
       perm.setResourceId(req.getFolderId());
       perm.setPrincipalType("USER");
       perm.setPrincipalId(req.getAuditorUserId());
       perm.setPermission("READ");
       perm.setGrantedBy(SecurityContextHelper.getCurrentUserId());
       perm.setExpiresAt(Instant.now().plus(
           req.getDurationDays(), ChronoUnit.DAYS));
       aclRepo.save(perm);
    }
}

Điểm hay ở đây là quyền không cần được cấp trên từng hợp đồng. Chỉ cần cấp trên folder dự án, sau đó cơ chế kế thừa sẽ lo phần còn lại. Như vậy, thiết kế vừa đúng nghiệp vụ vừa tiết kiệm chi phí vận hành.

10. các điểm hay bị bỏ sót trong thực tế

Nhiều hệ thống đã đi khá xa trong authorization nhưng vẫn bị thủng ở các góc ít người để ý. Phần này rất quan trọng vì nó cho thấy chống IDOR không chỉ là chặn endpoint đọc tài liệu.

10.1. batch operation phải kiểm tra từng phần tử

Trong thao tác xóa hàng loạt, cập nhật hàng loạt hoặc chia sẻ hàng loạt, mỗi ID bên trong request phải được kiểm tra độc lập.

</> java
for (Long contractId : contractIds) {
    boolean allowed = authChecker.checkById(
        auth, contractId, "DELETE");

    if (!allowed) {
       result.addDenied(contractId,
           "Không có quyền xóa hợp đồng này");
        continue;
    }
   contractRepo.softDeleteById(contractId, Instant.now());
   result.addSuccess(contractId);
}

Đây là điểm mà nhiều hệ thống làm sai do áp lực hiệu năng hoặc do thói quen tư duy theo request thay vì theo resource. Nhưng authorization đúng nghĩa luôn là quyết định trên từng resource.

Cách an toàn hơn là gom cả AccessDeniedException và ResourceNotFoundException về cùng một phản hồi 404.

</> Java
@ControllerAdvice
public class SecurityExceptionHandler {

   @ExceptionHandler({
       AccessDeniedException.class,
       ResourceNotFoundException.class
    })
    public ResponseEntity<ErrorResponse> handleAccessOrNotFound(
            Exception ex, HttpServletRequest req) {
        return ResponseEntity
           .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("Không tìm thấy tài nguyên"));
    }
}

Lúc này, hệ thống không tiết lộ sự tồn tại của resource cho người không có quyền. Đây là một chi tiết nhỏ nhưng có giá trị phòng thủ rất lớn.

10.3. uuid không thay thế authorization, nhưng làm enumeration khó hơn rất nhiều

Dùng UUID v4 thay cho ID tuần tự trong API public không tự làm hệ thống an toàn. Nếu authorization sai, UUID vẫn không cứu được. Tuy nhiên, UUID làm chi phí đoán object tăng lên rất lớn, nên nó là một lớp làm mờ hữu ích. Xem thêm bài Opaque ID.

</> Java
@Entity
public class Contract {
    @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, updatable = false)
    private UUID uuid = UUID.randomUUID();
}

Cách hiểu đúng là thế này. Authorization là lớp quyết định chính. UUID là lớp hỗ trợ giảm khả năng bị enumerate hàng loạt. Hai thứ này đi cùng nhau thì tốt. Dùng UUID để thay thế authorization là sai. 

10.4. list query cũng phải phân quyền từ trong sql

Một lỗi rất phổ biến là lấy toàn bộ dữ liệu ra rồi mới filter trong memory.

</> Java
public List<ContractDto> listContracts_WRONG(UserPrincipal user) {
    return contractRepo.findAll()
        .stream()
        .filter(c -> c.getTenantId().equals(user.getTenantId()))
       .map(mapper::toDto)
       .collect(Collectors.toList());
}

Cách này không chỉ kém hiệu năng mà còn nguy hiểm. Nếu quên filter ở một nhánh nào đó, dữ liệu sẽ lộ ngay. Cách đúng là đẩy điều kiện phân quyền xuống câu truy vấn.

</> Java
public Page<ContractDto> listContracts(
        UserPrincipal user, Pageable pageable) {
    boolean isManager = hasRole(user, "CONTRACT_MANAGER");
   Page<Contract> page;

    if (isManager) {
        page = contractRepo.findByTenantIdAndDepartment(
           user.getTenantId(), user.getDepartment(), pageable);
    } else {
        page = contractRepo.findAccessibleContracts(
           user.getTenantId(), user.getId(), pageable);
    }
    return page.map(mapper::toDto);
}

Một khi hiểu IDOR là vấn đề của quyền trên object, ta sẽ thấy query list cũng là object level authorization, chỉ khác là nó quyết định trên một tập object thay vì một object đơn lẻ.

11. audit log và giám sát không phải phần phụ

Khi nói về phân quyền, nhiều đội chỉ nghĩ đến chặn truy cập trái phép. Nhưng với hệ thống thực tế, ngăn chặn thôi là chưa đủ. Ta còn cần biết ai đã thử truy cập cái gì, khi nào, thành công hay bị từ chối, và xu hướng truy cập đó có bất thường hay không.

Đó là lý do audit log phải được xem là một phần của kiến trúc bảo vệ chứ không phải tính năng phụ. Bài gốc dùng Spring AOP để log các lần truy cập tài nguyên, kể cả các lần bị từ chối. Đây là một lựa chọn tốt vì không làm business logic bị ô nhiễm quá nhiều. 

Nếu hệ thống chỉ ghi log các lần thành công mà bỏ qua các lần bị từ chối, ta sẽ mất đi tín hiệu rất quan trọng của brute force, enumeration và probing. Trong bối cảnh IDOR, các chuỗi truy cập thất bại liên tiếp vào các object lân cận thường là dấu hiệu cảnh báo sớm rất rõ.

12. không có kiến trúc authorization nào hoàn chỉnh nếu không có test

Đi đến phần này, ta đã có đủ các lớp. Mô hình quyền, schema, ACL, kế thừa, service guard, repository guard, error handling, audit. Nhưng nếu không kiểm thử, toàn bộ cấu trúc đó vẫn chỉ là niềm tin.

Bộ integration test trong bài gốc rất đúng hướng vì nó kiểm tra từ HTTP request đến database, không dừng ở unit test của từng method riêng lẻ. Các tình huống được chọn cũng phản ánh đúng các ranh giới bảo mật quan trọng.

  1. User A tạo hợp đồng, user B cùng tenant không được đọc. 
  2. Admin tenant 2 không được đọc dữ liệu tenant 1. 
  3. User B đọc được tài nguyên khi đã được share đúng cách. 
  4. Quyền đã hết hạn thì phải bị từ chối. 

Điểm rất đáng chú ý là kỳ vọng phản hồi ở các trường hợp bị từ chối là 404 chứ không phải 403. Điều này cho thấy test không chỉ kiểm tra quyền, mà còn kiểm tra cả chính sách che giấu tồn tại của resource. Đây là một chi tiết nhỏ nhưng rất quan trọng nếu muốn bài toán authorization được đóng kín thật sự. 

Lời Cuối

Nếu nhìn lại toàn bộ, ta sẽ thấy IDOR không phải một lỗi bất ngờ, càng không phải một lỗi nhỏ ở URL. Nó là hệ quả logic của việc hệ thống chỉ biết xác thực người dùng, hoặc chỉ biết phân quyền theo role, nhưng không biết gắn người dùng đó với tài nguyên cụ thể mà họ đang yêu cầu. 

Vì vậy, muốn chặn IDOR một cách bài bản, hệ thống cần đi theo một hướng rất rõ.

  • Dữ liệu phải lưu được ownership và tenant boundary.
  • Quyền tường minh phải có nơi lưu trữ chính thức như ACL.
  • Nếu tài nguyên có cấu trúc cha con thì phải hỗ trợ kế thừa quyền.
  • Spring Security không nên chỉ dừng ở hasRole, mà cần tiến tới hasPermission theo từng resource.
  • Repository và query cũng phải tự mang logic bảo vệ, không đẩy toàn bộ gánh nặng lên service.
  • Lỗi trả về phải không làm lộ sự tồn tại của object.
  • Các lần bị từ chối phải được audit và giám sát.
  • Mọi giả định bảo mật phải được kiểm thử bằng integration test.

IDOR không được xử lý tốt bằng các bản vá rời rạc. Nó cần một kiến trúc authorization nhất quán từ database, đến repository, đến service, đến error handling, đến giám sát. Khi hệ thống đạt được điều đó, ta không chỉ vá một lỗ hổng. Ta nâng cấp toàn bộ cách ứng dụng suy nghĩ về quyền truy cập.