-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] 권한 pre-filter 구성 #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
70b3343
dfc03f8
b2dedec
523032f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` | ||
|
|
||
| ``` | ||
| findReadableDocumentIds(userId, collectionId) | ||
| ├─ collectionId == null → findReadableDocumentIds(userId) | ||
| └─ collectionId != null → findReadableDocumentIdsInCollection(userId, collectionId) | ||
| ``` | ||
|
|
||
| - `@Transactional(readOnly = true)` — 읽기 전용 | ||
| - 빈 목록 반환 시 호출 측(Issue 5 SearchFacade)에서 벡터 검색을 건너뛸 수 있도록 그대로 반환 | ||
| - 현재 이슈 범위에서는 빈 목록 fast-path 처리를 서비스 내부에서 수행하지 않는다 (호출 측 책임) | ||
|
|
||
| --- | ||
|
|
||
| ## 에러 케이스 정리 | ||
|
|
||
| | 상황 | 처리 방식 | | ||
| |------|-----------| | ||
| | 접근 가능한 문서 없음 | 빈 `List<Long>` 반환. 호출 측에서 벡터 검색 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 플래너 의존적이며, | ||
| 현재는 가독성과 중복 제거 측면에서 서브쿼리 감싸기가 더 유리하다고 판단했다. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 서비스. | ||
| * | ||
| * <p>벡터 검색 실행 전에 사용자가 읽을 수 있는 문서 ID 목록을 반환한다. | ||
| * 결과가 빈 목록이면 호출 측에서 벡터 검색을 건너뛰어야 한다. | ||
| * | ||
| * <p>접근 가능 조건 (OR): | ||
| * <ul> | ||
| * <li>OWNER — 문서 소유자</li> | ||
| * <li>PUBLIC — visibility = PUBLIC</li> | ||
| * <li>USER 캐시 — user_document_access_cache에 유효한 읽기 캐시 존재</li> | ||
| * <li>ROLE live — 사용자 역할 기반 document_permissions 또는 collection_permissions</li> | ||
| * <li>DEPT live — 사용자 부서 기반 document_permissions 또는 collection_permissions</li> | ||
| * </ul> | ||
| */ | ||
| @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<Long> findReadableDocumentIds(Long userId, Long collectionId) { | ||
| if (collectionId != null) { | ||
| return documentRepository.findReadableDocumentIdsInCollection(userId, collectionId); | ||
| } | ||
| return documentRepository.findReadableDocumentIds(userId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 { | ||||||||||||||||||||||
|
Comment on lines
+19
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 테스트 클래스의 역할과 경계를 클래스 주석으로 명시하세요.
수정 예시+/**
+ * AccessibleDocumentQueryService의 저장소 호출 분기를 단위 테스트한다.
+ * 네이티브 권한 SQL 검증은 저장소 통합 테스트 범위다.
+ */
`@ExtendWith`(MockitoExtension.class)
`@DisplayName`("AccessibleDocumentQueryService 단위 테스트")
class AccessibleDocumentQueryServiceTest {As per coding guidelines, "Every newly created class, interface, or record must have a class-level comment describing its role, responsibility, and boundary." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @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<Long> expected = List.of(1L, 2L, 3L); | ||||||||||||||||||||||
| given(documentRepository.findReadableDocumentIds(USER_ID)).willReturn(expected); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| List<Long> 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<Long> expected = List.of(2L, 3L); | ||||||||||||||||||||||
| given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID)).willReturn(expected); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| List<Long> 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<Long> 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<Long> result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, COLLECTION_ID); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| assertThat(result).isEmpty(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+32
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 네이티브 권한 SQL을 검증하는 저장소 통합 테스트를 추가하세요. 현재 테스트는 As per path instructions, "src/test/**/*.java: 테스트 커버리지, 스프링 테스트 어노테이션, mock 사용법, 네이밍 규칙을 확인한다." 🤖 Prompt for AI AgentsSource: Path instructions |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.