Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/design/kangcheolung-#54-search-permission-pre-filter.md
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)
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

- `@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
Expand Up @@ -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;
Expand Down Expand Up @@ -46,4 +45,106 @@ List<DocumentStatusProjection> findDocumentStatus(
@Param("processingVersionStatuses") Collection<DocumentVersionStatus> processingVersionStatuses,
@Param("activeJobStatuses") Collection<EmbeddingJobStatus> 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<Long> 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<Long> findReadableDocumentIdsInCollection(
@Param("userId") Long userId,
@Param("collectionId") Long collectionId
);
}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

테스트 클래스의 역할과 경계를 클래스 주석으로 명시하세요.

@DisplayName은 클래스 수준 주석을 대체하지 않습니다.

수정 예시
+/**
+ * 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@ExtendWith(MockitoExtension.class)
@DisplayName("AccessibleDocumentQueryService 단위 테스트")
class AccessibleDocumentQueryServiceTest {
/**
* AccessibleDocumentQueryService의 저장소 호출 분기를 단위 테스트한다.
* 네이티브 권한 SQL 검증은 저장소 통합 테스트 범위다.
*/
`@ExtendWith`(MockitoExtension.class)
`@DisplayName`("AccessibleDocumentQueryService 단위 테스트")
class AccessibleDocumentQueryServiceTest {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java`
around lines 19 - 21, 클래스 수준 주석이 없어 AccessibleDocumentQueryServiceTest의 역할과 책임
범위가 드러나지 않습니다. `@DisplayName` 위 또는 클래스 선언부에 주석을 추가해 이 테스트 클래스가
AccessibleDocumentQueryService를 단위 테스트하며 테스트 대상과 범위를 명확히 설명하도록 하세요.

Source: 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

네이티브 권한 SQL을 검증하는 저장소 통합 테스트를 추가하세요.

현재 테스트는 DocumentRepository를 mock 처리해 라우팅만 확인합니다. 따라서 OWNER, PUBLIC, USER 캐시의 만료·무효화, 문서/컬렉션 ROLE·DEPARTMENT 권한, 무권한 및 컬렉션 교집합이 실제 SQL에서 올바르게 필터링되는지 검증하지 못합니다. @DataJpaTest로 각 권한 경로와 빈 결과를 실행 검증하세요.

As per path instructions, "src/test/**/*.java: 테스트 커버리지, 스프링 테스트 어노테이션, mock 사용법, 네이밍 규칙을 확인한다."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java`
around lines 32 - 76, Replace the mock-based tests in
AccessibleDocumentQueryServiceTest with a `@DataJpaTest` integration test setup
that executes the native DocumentRepository queries against an actual test
database. Add fixtures and assertions covering OWNER, PUBLIC, and USER cache
expiration/invalidation, document and collection ROLE/DEPARTMENT permissions,
unauthorized documents, and collection-intersection filtering, including
empty-result cases; retain clear scenario-based test names and verify both
global and collection query methods.

Source: Path instructions

}