diff --git a/docs/design/kangcheolung-#54-search-permission-pre-filter.md b/docs/design/kangcheolung-#54-search-permission-pre-filter.md new file mode 100644 index 00000000..8164c83e --- /dev/null +++ b/docs/design/kangcheolung-#54-search-permission-pre-filter.md @@ -0,0 +1,97 @@ +# #54 검색 블록 — 권한 pre-filter (F-SEARCH-04) + +closes #54 + +## 배경 + +벡터 검색을 실행하기 전에 사용자가 읽을 수 있는 문서 ID 목록을 먼저 확보해야 한다. +이 목록을 pgvector의 `<->` 거리 연산에 `WHERE document_id IN (...)` 형태로 전달해 +접근 불가 문서가 검색 결과에 노출되지 않도록 막는 것이 이 이슈의 목표다. + +실제 `POST /search` API 조립과 벡터 쿼리 실행은 Issue 5에서 진행한다. +이 이슈는 "어떤 문서 ID를 검색 대상으로 쓸 것인가"를 결정하는 pre-filter 서비스 구현에 집중한다. + +--- + +## 작업 내용 + +### 1. `DocumentRepository` — UNION 네이티브 쿼리 2종 추가 + +5가지 접근 경로를 UNION으로 합산해 한 번의 쿼리로 접근 가능한 문서 ID 전체를 반환한다. +공통 조건: `deleted_at IS NULL AND status = 'INDEXED'` + +| 브랜치 | 테이블 조합 | 설명 | +|---|---|---| +| OWNER | `documents` | `owner_user_id = userId` | +| PUBLIC | `documents` | `visibility = 'PUBLIC'` | +| USER 캐시 | `documents` + `user_document_access_cache` | 유효한 읽기 캐시 존재 (`invalidated_at IS NULL`, 만료 미포함) | +| ROLE live (문서) | `documents` + `document_permissions` + `user_roles` | `target_type = 'ROLE'`, `can_read = true`, 만료 미포함 | +| DEPT live (문서) | `documents` + `document_permissions` + `users` | `target_type = 'DEPARTMENT'`, `can_read = true`, 만료 미포함 | +| ROLE live (컬렉션) | `documents` + `collection_documents` + `collection_permissions` + `user_roles` | 컬렉션 권한 → 문서, ROLE | +| DEPT live (컬렉션) | `documents` + `collection_documents` + `collection_permissions` + `users` | 컬렉션 권한 → 문서, DEPT | + +#### `findReadableDocumentIds(userId)` — 전체 범위 + +위 7개 브랜치를 UNION으로 합산한 단일 쿼리. + +#### `findReadableDocumentIdsInCollection(userId, collectionId)` — 컬렉션 범위 + +전체 UNION을 서브쿼리(`sub`)로 감싸고, `collection_documents`의 `collection_id = :collectionId` 조건으로 교집합을 구한다. + +```sql +SELECT sub.id FROM ( ... UNION ... ) sub +WHERE sub.id IN ( + SELECT cd_filter.document_id FROM collection_documents cd_filter + WHERE cd_filter.collection_id = :collectionId +) +``` + +`collection_documents`에 `idx_collection_documents_collection_id` 인덱스가 있으므로 IN 서브쿼리 성능은 안정적이다. + +--- + +### 2. `AccessibleDocumentQueryService` (신규) + +`domain/search/service/query/AccessibleDocumentQueryService.java` + +```text +findReadableDocumentIds(userId, collectionId) + ├─ collectionId == null → findReadableDocumentIds(userId) + └─ collectionId != null → findReadableDocumentIdsInCollection(userId, collectionId) +``` + +- `@Transactional(readOnly = true)` — 읽기 전용 +- 빈 목록 반환 시 호출 측(Issue 5 SearchFacade)에서 벡터 검색을 건너뛸 수 있도록 그대로 반환 +- 현재 이슈 범위에서는 빈 목록 fast-path 처리를 서비스 내부에서 수행하지 않는다 (호출 측 책임) + +--- + +## 에러 케이스 정리 + +| 상황 | 처리 방식 | +|------|-----------| +| 접근 가능한 문서 없음 | 빈 `List` 반환. 호출 측에서 벡터 검색 skip | +| INDEXED 상태가 아닌 문서 | UNION 쿼리 조건 `status = 'INDEXED'`로 자동 제외 | +| soft delete된 문서 | `deleted_at IS NULL` 조건으로 자동 제외 | +| 만료된 권한 | `expires_at IS NULL OR expires_at > NOW()` 조건으로 자동 제외 | +| 무효화된 캐시 | `invalidated_at IS NULL` 조건으로 자동 제외 | + +--- + +## 설계 결정 + +**UNION 방식 선택 이유** + +단건 boolean 체크(기존 `existsRoleReadPermission` 등)를 반복 호출하는 방식은 검색 대상 문서 수가 증가할수록 N번의 쿼리가 발생한다. +UNION 방식은 접근 경로별로 DB가 병렬 처리할 수 있고, 결과는 Set의 합집합으로 중복 없이 반환된다. + +**`collectionId` nullable 처리** + +컬렉션 범위 검색은 선택적 기능이다. null이면 전체 범위, 값이 있으면 컬렉션 범위로 자연스럽게 분기한다. +서비스 메서드 시그니처를 `(userId, collectionId)` 단일 진입점으로 유지해 Issue 5 조립 시 호출 코드가 단순해진다. + +**외부 서브쿼리 vs 각 브랜치 개별 필터** + +컬렉션 범위 쿼리에서 "UNION 전체를 서브쿼리로 감싸고 외부에서 컬렉션 필터 적용" 방식을 선택했다. +각 브랜치마다 `AND d.id IN (SELECT ...)` 조건을 추가하는 방식과 성능 차이는 PostgreSQL 플래너 의존적이며, +현재는 가독성과 중복 제거 측면에서 서브쿼리 감싸기가 더 유리하다고 판단했다. diff --git a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index 1c9b0eb6..f39ae433 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -4,14 +4,13 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.JpaRepository; - import com.opensource.docgrid.domain.document.entity.Document; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; @@ -46,4 +45,106 @@ List findDocumentStatus( @Param("processingVersionStatuses") Collection processingVersionStatuses, @Param("activeJobStatuses") Collection activeJobStatuses ); + + // 검색 pre-filter — 사용자가 읽을 수 있는 INDEXED 문서 ID 전체 (컬렉션 미지정) + // 5가지 접근 경로: OWNER / PUBLIC / USER캐시 / ROLE live / DEPT live (문서·컬렉션 권한 모두 포함) + @Query(value = """ + SELECT d.id FROM documents d + WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + WHERE d.visibility = 'PUBLIC' AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN user_document_access_cache c ON c.document_id = d.id + WHERE c.user_id = :userId AND c.can_read = true AND c.invalidated_at IS NULL + AND (c.expires_at IS NULL OR c.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN document_permissions dp ON dp.document_id = d.id + JOIN user_roles ur ON ur.role_id = dp.role_id + WHERE dp.target_type = 'ROLE' AND ur.user_id = :userId AND dp.can_read = true + AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN document_permissions dp ON dp.document_id = d.id + JOIN users u ON u.department_id = dp.department_id + WHERE dp.target_type = 'DEPARTMENT' AND u.id = :userId AND dp.can_read = true + AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN collection_documents cd ON cd.document_id = d.id + JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN collection_documents cd ON cd.document_id = d.id + JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + """, nativeQuery = true) + List findReadableDocumentIds(@Param("userId") Long userId); + + // 검색 pre-filter — 특정 컬렉션 내에서 사용자가 읽을 수 있는 INDEXED 문서 ID + @Query(value = """ + SELECT sub.id FROM ( + SELECT d.id FROM documents d + WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + WHERE d.visibility = 'PUBLIC' AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN user_document_access_cache c ON c.document_id = d.id + WHERE c.user_id = :userId AND c.can_read = true AND c.invalidated_at IS NULL + AND (c.expires_at IS NULL OR c.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN document_permissions dp ON dp.document_id = d.id + JOIN user_roles ur ON ur.role_id = dp.role_id + WHERE dp.target_type = 'ROLE' AND ur.user_id = :userId AND dp.can_read = true + AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN document_permissions dp ON dp.document_id = d.id + JOIN users u ON u.department_id = dp.department_id + WHERE dp.target_type = 'DEPARTMENT' AND u.id = :userId AND dp.can_read = true + AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN collection_documents cd ON cd.document_id = d.id + JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + UNION + SELECT d.id FROM documents d + JOIN collection_documents cd ON cd.document_id = d.id + JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND d.deleted_at IS NULL AND d.status = 'INDEXED' + ) sub + WHERE sub.id IN ( + SELECT cd_filter.document_id FROM collection_documents cd_filter + WHERE cd_filter.collection_id = :collectionId + ) + """, nativeQuery = true) + List findReadableDocumentIdsInCollection( + @Param("userId") Long userId, + @Param("collectionId") Long collectionId + ); } diff --git a/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java b/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java new file mode 100644 index 00000000..49647e69 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java @@ -0,0 +1,47 @@ +package com.opensource.docgrid.domain.search.service.query; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.document.repository.DocumentRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 검색 pre-filter 서비스. + * + *

벡터 검색 실행 전에 사용자가 읽을 수 있는 문서 ID 목록을 반환한다. + * 결과가 빈 목록이면 호출 측에서 벡터 검색을 건너뛰어야 한다. + * + *

접근 가능 조건 (OR): + *

    + *
  • OWNER — 문서 소유자
  • + *
  • PUBLIC — visibility = PUBLIC
  • + *
  • USER 캐시 — user_document_access_cache에 유효한 읽기 캐시 존재
  • + *
  • ROLE live — 사용자 역할 기반 document_permissions 또는 collection_permissions
  • + *
  • DEPT live — 사용자 부서 기반 document_permissions 또는 collection_permissions
  • + *
+ */ +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AccessibleDocumentQueryService { + + private final DocumentRepository documentRepository; + + /** + * 사용자가 읽을 수 있는 INDEXED 문서 ID 목록을 반환한다. + * + * @param userId 요청 사용자 ID + * @param collectionId 컬렉션 범위 검색 시 컬렉션 ID, 전체 검색이면 null + * @return 접근 가능한 문서 ID 목록 (빈 목록이면 검색 불필요) + */ + public List findReadableDocumentIds(Long userId, Long collectionId) { + if (collectionId != null) { + return documentRepository.findReadableDocumentIdsInCollection(userId, collectionId); + } + return documentRepository.findReadableDocumentIds(userId); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java new file mode 100644 index 00000000..fd492249 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java @@ -0,0 +1,77 @@ +package com.opensource.docgrid.domain.search.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.document.repository.DocumentRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AccessibleDocumentQueryService 단위 테스트") +class AccessibleDocumentQueryServiceTest { + + @InjectMocks + private AccessibleDocumentQueryService accessibleDocumentQueryService; + + @Mock + private DocumentRepository documentRepository; + + private static final Long USER_ID = 1L; + private static final Long COLLECTION_ID = 10L; + + @Test + @DisplayName("collectionId가 null이면 전체 범위 쿼리를 호출하고 결과를 반환한다") + void findReadableDocumentIds_withoutCollection_callsGlobalQuery() { + List expected = List.of(1L, 2L, 3L); + given(documentRepository.findReadableDocumentIds(USER_ID)).willReturn(expected); + + List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null); + + assertThat(result).isEqualTo(expected); + then(documentRepository).should(times(1)).findReadableDocumentIds(USER_ID); + then(documentRepository).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("collectionId가 있으면 컬렉션 범위 쿼리를 호출하고 결과를 반환한다") + void findReadableDocumentIds_withCollection_callsCollectionQuery() { + List expected = List.of(2L, 3L); + given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID)).willReturn(expected); + + List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, COLLECTION_ID); + + assertThat(result).isEqualTo(expected); + then(documentRepository).should(times(1)).findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID); + then(documentRepository).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("접근 가능한 문서가 없으면 빈 목록을 반환한다") + void findReadableDocumentIds_noAccessible_returnsEmptyList() { + given(documentRepository.findReadableDocumentIds(USER_ID)).willReturn(List.of()); + + List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("컬렉션 범위에서 접근 가능한 문서가 없으면 빈 목록을 반환한다") + void findReadableDocumentIds_noAccessibleInCollection_returnsEmptyList() { + given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID)).willReturn(List.of()); + + List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, COLLECTION_ID); + + assertThat(result).isEmpty(); + } +}