Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 13 additions & 1 deletion backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ dependencies {

tasks.named('test') {
useJUnitPlatform {
excludeTags 'permission-durability', 'benchmark', 'dashboard-cloud-load', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'section-path-quality', 'opensql-ha-connection', 'opensql-ha-contract', 'opensql-outbox-isolated'
excludeTags 'permission-standby', 'permission-durability', 'benchmark', 'dashboard-cloud-load', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'section-path-quality', 'opensql-ha-connection', 'opensql-ha-contract', 'opensql-outbox-isolated'
}
}

Expand Down Expand Up @@ -618,3 +618,15 @@ tasks.register('permissionDurabilityTest', Test) {
outputs.upToDateWhen { false }
testLogging { events 'passed', 'failed'; showStandardStreams = true }
}

// Primary 원장 인가가 standby 연결에서 허용되지 않는지 실제 복제본으로 확인한다.
tasks.register('permissionStandbyTest', Test) {
group = 'verification'
dependsOn permissionDurabilityTest
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'permission-standby' }
maxParallelForks = 1
outputs.upToDateWhen { false }
testLogging { events 'passed', 'failed'; showStandardStreams = true }
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus;
import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob;
import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository;
import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService;
import com.opensource.docgrid.domain.permission.service.query.PrimaryDocumentPermissionQueryService;
import com.opensource.docgrid.global.common.response.PageResponse;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;
Expand Down Expand Up @@ -85,7 +85,7 @@ public class DocumentQueryService {
private final DocumentVersionRepository documentVersionRepository;
private final DocumentChunkRepository documentChunkRepository;
private final EmbeddingJobRepository embeddingJobRepository;
private final PermissionQueryService permissionQueryService;
private final PrimaryDocumentPermissionQueryService permissionQueryService;
private final DocumentDetailConverter documentDetailConverter;
private final DocumentStatusConverter documentStatusConverter;
private final DocumentSummaryConverter documentSummaryConverter;
Expand Down Expand Up @@ -121,6 +121,8 @@ public PageResponse<DocumentSummaryResponse> getMyDocuments(
/**
* 읽기 가능한 문서의 Metadata와 현재 버전 요약을 반환한다.
*/
// 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다.
@Transactional
public DocumentDetailResponse getDocumentDetail(Long userId, Long documentId) {
// 1. 권한과 삭제 상태를 검증한 상세 조회용 Entity를 가져온다.
Document document = getReadableDocument(userId, documentId);
Expand All @@ -135,6 +137,8 @@ public DocumentDetailResponse getDocumentDetail(Long userId, Long documentId) {
/**
* 읽기 가능한 문서의 모든 버전과 각 버전의 최신 인덱싱 Job을 타임라인 순서로 반환한다.
*/
// 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다.
@Transactional
public List<DocumentVersionHistoryResponse> getDocumentVersions(Long userId, Long documentId) {
// 1. 상세 조회와 같은 권한·삭제 정책으로 타임라인 대상 문서를 확정한다.
Document document = getReadableDocument(userId, documentId);
Expand Down Expand Up @@ -164,6 +168,8 @@ public List<DocumentVersionHistoryResponse> getDocumentVersions(Long userId, Lon
/**
* 현재 버전의 Chunk를 검증하고 중복을 제거해 정규화 Text 전체를 복원한다.
*/
// 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다.
@Transactional
public DocumentContentResponse getDocumentContent(Long userId, Long documentId) {
// 1. 읽기 가능한 문서와 현재 버전을 확정한다.
Document document = getReadableDocument(userId, documentId);
Expand Down Expand Up @@ -196,6 +202,8 @@ public DocumentContentResponse getDocumentContent(Long userId, Long documentId)
/**
* 원본 파일 I/O를 Transaction 밖에서 수행할 수 있도록 현재 버전의 저장 위치만 Snapshot으로 반환한다.
*/
// 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다.
@Transactional
public DocumentFileSnapshot getDocumentFileSnapshot(Long userId, Long documentId) {
// 1. 동일한 읽기 권한과 삭제 문서 차단 규칙을 원본 파일에도 적용한다.
Document document = getReadableDocument(userId, documentId);
Expand Down Expand Up @@ -226,6 +234,8 @@ public DocumentFileSnapshot getDocumentFileSnapshot(Long userId, Long documentId
/**
* 문서의 현재 검색 버전과 별도로 처리 중인 버전·Job 상태를 일관된 Projection으로 조회한다.
*/
// 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다.
@Transactional
public DocumentStatusResponse getDocumentStatus(Long userId, Long documentId) {
// 1. 상태 정보도 문서 본문과 같은 읽기 권한 경계를 적용한다.
if (!permissionQueryService.canReadDocument(userId, documentId)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,38 +85,52 @@ List<Long> findDocumentIdsByUserIdAndSourceTypeAndSourceId(
@Param("sourceId") Long sourceId);

/**
* 그룹 4 — 유효성 존재 체크 (3개)
* 그룹 4 — 원장과 교차 검증한 유효성 존재 체크
*
* <p>PermissionQueryService의 "3단계: USER 캐시" 판단에서 호출된다.
*/

// 유효한 읽기 캐시 존재 여부 (invalidated_at IS NULL, 만료 미포함)
@Query("""
SELECT COUNT(c) > 0 FROM UserDocumentAccessCache c
WHERE c.user.id = :userId AND c.document.id = :documentId
AND c.canRead = true
AND c.invalidatedAt IS NULL
AND (c.expiresAt IS NULL OR c.expiresAt > CURRENT_TIMESTAMP)
""")
boolean existsValidReadCache(@Param("userId") Long userId, @Param("documentId") Long documentId);

// 유효한 쓰기 캐시 존재 여부
@Query("""
SELECT COUNT(c) > 0 FROM UserDocumentAccessCache c
WHERE c.user.id = :userId AND c.document.id = :documentId
AND c.canWrite = true
AND c.invalidatedAt IS NULL
AND (c.expiresAt IS NULL OR c.expiresAt > CURRENT_TIMESTAMP)
""")
boolean existsValidWriteCache(@Param("userId") Long userId, @Param("documentId") Long documentId);

// 유효한 관리 캐시 존재 여부
@Query("""
SELECT COUNT(c) > 0 FROM UserDocumentAccessCache c
WHERE c.user.id = :userId AND c.document.id = :documentId
AND c.canAdmin = true
AND c.invalidatedAt IS NULL
AND (c.expiresAt IS NULL OR c.expiresAt > CURRENT_TIMESTAMP)
""")
boolean existsValidAdminCache(@Param("userId") Long userId, @Param("documentId") Long documentId);
// 캐시는 후보이며, 출처 원장의 현재 대상·권한·만료·컬렉션 연결까지 확인해야 최종 허용한다.
default boolean existsValidReadCache(Long userId, Long documentId) {
return existsAuthoritativeCache(userId, documentId, "READ");
}

default boolean existsValidWriteCache(Long userId, Long documentId) {
return existsAuthoritativeCache(userId, documentId, "WRITE");
}

default boolean existsValidAdminCache(Long userId, Long documentId) {
return existsAuthoritativeCache(userId, documentId, "ADMIN");
}

/** 현재 원장이 증명하는 USER 캐시만 허용하며 오래된 출처·대상·접근 비트는 거부한다. */
@Query(value = """
SELECT EXISTS (
SELECT 1 FROM user_document_access_cache c
JOIN documents d ON d.id=c.document_id
WHERE c.user_id=:userId AND c.document_id=:documentId
AND c.invalidated_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>CURRENT_TIMESTAMP)
AND ((:capability='READ' AND c.can_read) OR (:capability='WRITE' AND c.can_write)
OR (:capability='ADMIN' AND c.can_admin))
AND (
(c.source_type='OWNER' AND d.owner_user_id=:userId)
OR (c.source_type='DIRECT_DOCUMENT_PERMISSION' AND EXISTS (
SELECT 1 FROM document_permissions p WHERE p.id=c.source_id
AND p.document_id=c.document_id AND p.target_type='USER' AND p.user_id=c.user_id
AND (p.expires_at IS NULL OR p.expires_at>CURRENT_TIMESTAMP)
AND ((:capability='READ' AND p.can_read) OR (:capability='WRITE' AND p.can_write)
OR (:capability='ADMIN' AND p.can_admin))))
OR (c.source_type='DIRECT_COLLECTION_PERMISSION' AND EXISTS (
SELECT 1 FROM collection_permissions p
JOIN collections k ON k.id=p.collection_id AND k.status='ACTIVE'
JOIN collection_documents cd ON cd.collection_id=p.collection_id AND cd.document_id=c.document_id
WHERE p.id=c.source_id AND p.target_type='USER' AND p.user_id=c.user_id
AND (p.expires_at IS NULL OR p.expires_at>CURRENT_TIMESTAMP)
AND ((:capability='READ' AND p.can_read) OR (:capability='WRITE' AND p.can_write)
OR (:capability='ADMIN' AND p.can_admin))))
)
)
""", nativeQuery = true)
boolean existsAuthoritativeCache(@Param("userId") Long userId, @Param("documentId") Long documentId,
@Param("capability") String capability);
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public boolean canReadDocument(Long userId, Long documentId) {
return true;
}

// 3단계: USER 캐시
// 3단계: USER 캐시와 출처 원장 교차 검증
if (cacheRepository.existsValidReadCache(userId, documentId)) {
return true;
}
Expand Down Expand Up @@ -122,7 +122,7 @@ public boolean canWriteDocument(Long userId, Long documentId) {
return true;
}

// 2단계: USER 캐시
// 2단계: USER 캐시와 출처 원장 교차 검증
if (cacheRepository.existsValidWriteCache(userId, documentId)) {
return true;
}
Expand Down Expand Up @@ -156,7 +156,7 @@ public boolean canAdminDocument(Long userId, Long documentId) {
return true;
}

// 2단계: USER 캐시
// 2단계: USER 캐시와 출처 원장 교차 검증
if (cacheRepository.existsValidAdminCache(userId, documentId)) {
return true;
}
Expand Down Expand Up @@ -207,7 +207,7 @@ public DocumentPermissionSummaryResponse checkDocumentPermission(Long userId, Lo
sources.add(PermissionSourceType.DEPARTMENT_VISIBILITY);
}

// 3단계: USER 캐시
// 3단계: USER 캐시와 출처 원장 교차 검증
boolean cacheRead = cacheRepository.existsValidReadCache(userId, documentId);
boolean cacheWrite = cacheRepository.existsValidWriteCache(userId, documentId);
boolean cacheAdmin = cacheRepository.existsValidAdminCache(userId, documentId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.opensource.docgrid.domain.permission.service.query;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

/**
* 새 문서 응답·검색 후보의 최종 읽기 인가를 현재 Primary 원장으로 검증한다.
* 호출자의 명시적 read-write 트랜잭션에 참여하며 standby·조회 실패를 허용으로 추정하지 않는다.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class PrimaryDocumentPermissionQueryService {
private final EntityManager entityManager;
private final PermissionQueryService permissions;

public boolean canReadDocument(Long userId, Long documentId) {
// 1. 같은 연결의 역할이 Primary인지 확인한다. read-only 라우팅 오동작도 닫힌 상태로 실패한다.
Object recovery = entityManager.createNativeQuery("SELECT pg_is_in_recovery()").getSingleResult();
if (!Boolean.FALSE.equals(recovery)) {
throw new DocGridException(ErrorCode.DOCUMENT_PERMISSION_UNAVAILABLE);
}
// 2. 새 트랜잭션 Snapshot에서 캐시 출처·현재 권한 원장을 함께 검증한다.
return permissions.canReadDocument(userId, documentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import com.opensource.docgrid.domain.embedding.dto.EmbedResult;
import com.opensource.docgrid.domain.embedding.service.query.QueryEmbeddingService;
import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService;
import com.opensource.docgrid.domain.permission.service.query.PrimaryDocumentPermissionQueryService;
import com.opensource.docgrid.domain.search.config.HybridSearchProperties;
import com.opensource.docgrid.domain.search.dto.ConversationContext;
import com.opensource.docgrid.domain.search.dto.SearchAdmission;
Expand Down Expand Up @@ -52,7 +52,7 @@ public class SearchFacade {
private final VectorSearchQueryService vectorSearchQueryService;
private final HybridSearchQueryService hybridSearchQueryService;
private final HybridSearchProperties hybridSearchProperties;
private final PermissionQueryService permissionQueryService;
private final PrimaryDocumentPermissionQueryService permissionQueryService;
private final SearchResultCommandService searchResultCommandService;

public SearchOutcome search(Long userId, SearchRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public enum ErrorCode {
ROLE_NOT_ASSIGNED(HttpStatus.NOT_FOUND, "ROLE-004", "부여되지 않은 역할입니다."),
ADMIN_ROLE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ROLE-005", "관리자 권한을 확인할 수 없습니다."),

DOCUMENT_PERMISSION_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "PERM-003", "현재 문서 권한을 확인할 수 없습니다."),
PERMISSION_REVOCATION_UNCONFIRMED(HttpStatus.SERVICE_UNAVAILABLE, "PERM-001", "권한 회수를 확정하지 못했습니다. 같은 요청 키로 다시 시도해 주세요."),
PERMISSION_REVOCATION_KEY_CONFLICT(HttpStatus.CONFLICT, "PERM-002", "다른 권한 회수에 사용한 요청 키입니다."),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus;
import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob;
import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository;
import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService;
import com.opensource.docgrid.domain.permission.service.query.PrimaryDocumentPermissionQueryService;
import com.opensource.docgrid.global.common.response.PageResponse;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;
Expand All @@ -68,7 +68,7 @@ class DocumentQueryServiceTest {
@Mock private DocumentVersionRepository documentVersionRepository;
@Mock private DocumentChunkRepository documentChunkRepository;
@Mock private EmbeddingJobRepository embeddingJobRepository;
@Mock private PermissionQueryService permissionQueryService;
@Mock private PrimaryDocumentPermissionQueryService permissionQueryService;
@Mock private DocumentDetailConverter documentDetailConverter;
@Mock private DocumentStatusConverter documentStatusConverter;
@Mock private DocumentSummaryConverter documentSummaryConverter;
Expand Down
Loading
Loading