diff --git a/backend/build.gradle b/backend/build.gradle index bf179fcb..345d2cbe 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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' } } @@ -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 } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java index b8f11a8e..50a14cb6 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java @@ -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; @@ -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; @@ -121,6 +121,8 @@ public PageResponse getMyDocuments( /** * 읽기 가능한 문서의 Metadata와 현재 버전 요약을 반환한다. */ + // 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다. + @Transactional public DocumentDetailResponse getDocumentDetail(Long userId, Long documentId) { // 1. 권한과 삭제 상태를 검증한 상세 조회용 Entity를 가져온다. Document document = getReadableDocument(userId, documentId); @@ -135,6 +137,8 @@ public DocumentDetailResponse getDocumentDetail(Long userId, Long documentId) { /** * 읽기 가능한 문서의 모든 버전과 각 버전의 최신 인덱싱 Job을 타임라인 순서로 반환한다. */ + // 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다. + @Transactional public List getDocumentVersions(Long userId, Long documentId) { // 1. 상세 조회와 같은 권한·삭제 정책으로 타임라인 대상 문서를 확정한다. Document document = getReadableDocument(userId, documentId); @@ -164,6 +168,8 @@ public List getDocumentVersions(Long userId, Lon /** * 현재 버전의 Chunk를 검증하고 중복을 제거해 정규화 Text 전체를 복원한다. */ + // 최종 인가와 문서 조회가 같은 Primary 연결을 사용하도록 read-only 라우팅을 피한다. + @Transactional public DocumentContentResponse getDocumentContent(Long userId, Long documentId) { // 1. 읽기 가능한 문서와 현재 버전을 확정한다. Document document = getReadableDocument(userId, documentId); @@ -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); @@ -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)) { diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java index a45db4b9..0658b129 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/UserDocumentAccessCacheRepository.java @@ -85,38 +85,52 @@ List findDocumentIdsByUserIdAndSourceTypeAndSourceId( @Param("sourceId") Long sourceId); /** - * 그룹 4 — 유효성 존재 체크 (3개) + * 그룹 4 — 원장과 교차 검증한 유효성 존재 체크 * *

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); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java index 68812bcd..dba290e3 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java @@ -88,7 +88,7 @@ public boolean canReadDocument(Long userId, Long documentId) { return true; } - // 3단계: USER 캐시 + // 3단계: USER 캐시와 출처 원장 교차 검증 if (cacheRepository.existsValidReadCache(userId, documentId)) { return true; } @@ -122,7 +122,7 @@ public boolean canWriteDocument(Long userId, Long documentId) { return true; } - // 2단계: USER 캐시 + // 2단계: USER 캐시와 출처 원장 교차 검증 if (cacheRepository.existsValidWriteCache(userId, documentId)) { return true; } @@ -156,7 +156,7 @@ public boolean canAdminDocument(Long userId, Long documentId) { return true; } - // 2단계: USER 캐시 + // 2단계: USER 캐시와 출처 원장 교차 검증 if (cacheRepository.existsValidAdminCache(userId, documentId)) { return true; } @@ -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); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PrimaryDocumentPermissionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PrimaryDocumentPermissionQueryService.java new file mode 100644 index 00000000..dee0b527 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PrimaryDocumentPermissionQueryService.java @@ -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); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java index 31d1b111..7171d976 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java @@ -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; @@ -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) { diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 7a342f75..b2f5ba38 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -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", "다른 권한 회수에 사용한 요청 키입니다."), diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java index 5f11881a..fb4d6360 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java @@ -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; @@ -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; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionDurabilityIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionDurabilityIntegrationTest.java index 0cb01e8e..fda7d2f6 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionDurabilityIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionDurabilityIntegrationTest.java @@ -75,8 +75,13 @@ class PermissionDurabilityIntegrationTest { private static final String URL = "jdbc:postgresql://127.0.0.1:55439/postgres"; private static final AtomicInteger COMMIT_WARNINGS = new AtomicInteger(); @Autowired DocumentPermissionCommandService commands; + @Autowired CollectionPermissionCommandService collectionCommands; + @Autowired com.opensource.docgrid.domain.collection.repository.CollectionRepository collections; + @Autowired com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository memberships; @Autowired com.opensource.docgrid.domain.permission.service.DocumentPermissionRevocationFacade revocationFacade; @Autowired PermissionQueryService queries; + @Autowired com.opensource.docgrid.domain.permission.service.query.PrimaryDocumentPermissionQueryService primaryPermissions; + @Autowired com.opensource.docgrid.domain.document.service.query.DocumentQueryService documentQueries; @Autowired UserRepository users; @Autowired DocumentRepository documents; @Autowired DataSource dataSource; @@ -89,10 +94,16 @@ class PermissionDurabilityIntegrationTest { @EntityScan("com.opensource.docgrid") @org.springframework.data.jpa.repository.config.EnableJpaAuditing @EnableJpaRepositories("com.opensource.docgrid") - @Import({DocumentPermissionCommandService.class, UserDocumentAccessCacheService.class, + @Import({DocumentPermissionCommandService.class, CollectionPermissionCommandService.class, UserDocumentAccessCacheService.class, PermissionQueryService.class, PermissionConverter.class, SyncEventWriter.class, com.opensource.docgrid.domain.permission.service.DocumentPermissionRevocationFacade.class, - com.opensource.docgrid.global.config.PermissionDurabilityConfiguration.class}) + com.opensource.docgrid.global.config.PermissionDurabilityConfiguration.class, + com.opensource.docgrid.domain.permission.service.query.PrimaryDocumentPermissionQueryService.class, + com.opensource.docgrid.domain.document.service.query.DocumentQueryService.class, + com.opensource.docgrid.domain.document.converter.DocumentDetailConverter.class, + com.opensource.docgrid.domain.document.converter.DocumentStatusConverter.class, + com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter.class, + com.opensource.docgrid.domain.document.converter.DocumentVersionHistoryConverter.class}) static class Config { @Bean Clock clock() { return Clock.systemDefaultZone(); } // 1. Hikari가 반환 시 지우기 전에 실제 물리 COMMIT의 경고 SQLSTATE만 관측한다. @@ -155,14 +166,34 @@ void baselineCommitCancellation() throws Exception { } @Test - @DisplayName("수정 전: 원장은 회수됐어도 오래된 USER 캐시는 최종 읽기 인가를 허용한다") + @DisplayName("회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다") void baselineStaleCache() { Fixture f = fixture(); assertThat(revoke(f)).isEqualTo(204); new JdbcTemplate(dataSource).update("UPDATE user_document_access_cache SET invalidated_at=NULL WHERE source_type='DIRECT_DOCUMENT_PERMISSION' AND source_id=?", f.permissionId()); assertThat(permissionCount(f)).isZero(); - assertThat(queries.canReadDocument(f.targetId(), f.documentId())).isTrue(); - event("baseline-stale-cache", "permission_rows=0 stale_cache=1 final_read_allowed=true"); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isFalse(); + assertThat(queries.canWriteDocument(f.targetId(), f.documentId())).isFalse(); + assertThat(queries.canAdminDocument(f.targetId(), f.documentId())).isFalse(); + var authentication = new UsernamePasswordAuthenticationToken("synthetic", "", java.util.List.of()); + authentication.setDetails(f.targetId()); + SecurityContextHolder.getContext().setAuthentication(authentication); + try { + var storage = mock(com.opensource.docgrid.domain.document.storage.FileStorageService.class); + var controller = new com.opensource.docgrid.domain.document.controller.DocumentQueryController(documentQueries, + new com.opensource.docgrid.domain.document.service.DocumentFileService(documentQueries, storage)); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new CurrentUserArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + for (String suffix : java.util.List.of("", "/content", "/file")) { + var response = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/documents/"+f.documentId()+suffix)).andReturn().getResponse(); + assertThat(response.getStatus()).isEqualTo(403); + } + org.mockito.Mockito.verifyNoInteractions(storage); + assertNewSearchFiltersRevokedContent(f); + } catch (Exception exception) { throw new IllegalStateException(exception); } + finally { SecurityContextHolder.clearContext(); } + event("authoritative-stale-cache", "permission_rows=0 stale_cache=1 detail_403=1 content_403=1 file_403=1 search_results=0 forbidden_content=0"); } @Test @@ -241,6 +272,83 @@ private long assertionCount(Fixture f) { return new JdbcTemplate(dataSource).queryForObject("SELECT coalesce(sum(assertion_count),0) FROM permission_revocation_requests WHERE request_id=?", Long.class, f.requestId()); } + + private void assertNewSearchFiltersRevokedContent(Fixture f) throws Exception { + var embedding = mock(com.opensource.docgrid.domain.embedding.service.query.QueryEmbeddingService.class); + var conversation = mock(com.opensource.docgrid.domain.search.service.query.SearchConversationQueryService.class); + var command = mock(com.opensource.docgrid.domain.search.service.command.SearchQueryCommandService.class); + var accessible = mock(com.opensource.docgrid.domain.search.service.query.AccessibleDocumentQueryService.class); + var vector = mock(com.opensource.docgrid.domain.search.service.query.VectorSearchQueryService.class); + var results = mock(com.opensource.docgrid.domain.search.service.command.SearchResultCommandService.class); + var model = mock(com.opensource.docgrid.domain.embedding.entity.EmbeddingModel.class); + org.mockito.Mockito.when(command.createProcessing(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyInt())) + .thenReturn(new com.opensource.docgrid.domain.search.dto.SearchAdmission(1L, 1L)); + org.mockito.Mockito.when(conversation.contextualizeRetrieval(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn("합성 질문"); + org.mockito.Mockito.when(embedding.embed(org.mockito.ArgumentMatchers.anyString())).thenReturn(new com.opensource.docgrid.domain.embedding.dto.EmbedResult(model, new float[1024])); + org.mockito.Mockito.when(accessible.findReadableDocumentIds(f.targetId(), null)).thenReturn(java.util.List.of(f.documentId())); + org.mockito.Mockito.when(vector.search(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt())) + .thenReturn(java.util.List.of(new com.opensource.docgrid.domain.search.dto.VectorSearchCandidate(1L, 1L, f.documentId(), "REVOKED_CONTENT_SENTINEL", 1, "합성 문서", java.math.BigDecimal.ONE))); + var search = new com.opensource.docgrid.domain.search.service.SearchFacade(embedding, conversation, command, accessible, + vector, mock(com.opensource.docgrid.domain.search.service.query.HybridSearchQueryService.class), + mock(com.opensource.docgrid.domain.search.config.HybridSearchProperties.class), primaryPermissions, results); + var rag = mock(com.opensource.docgrid.domain.rag.service.RagFacade.class); + org.mockito.Mockito.when(rag.enqueue(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyList())) + .thenReturn(com.opensource.docgrid.domain.rag.dto.RagEnqueueOutcome.stillPending()); + var controller = new com.opensource.docgrid.domain.search.controller.SearchController(search, rag, + mock(com.opensource.docgrid.domain.search.service.query.SearchAnswerQueryService.class), conversation); + var response = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new CurrentUserArgumentResolver()).setControllerAdvice(new GlobalExceptionHandler()).build() + .perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/search") + .contentType("application/json").accept("application/json").content("{\"queryText\":\"합성 질문\",\"topK\":5}")) + .andReturn().getResponse(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).doesNotContain("REVOKED_CONTENT_SENTINEL"); + var tree = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.getContentAsString()); + assertThat(tree.path("data").path("results").size()).isZero(); + } + + @Test + @DisplayName("유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다") + void liveGrantAndAlternateAccessRemainValid() { + Fixture f = fixture(); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isTrue(); + new JdbcTemplate(dataSource).update("UPDATE user_document_access_cache SET can_admin=true WHERE source_id=? AND source_type='DIRECT_DOCUMENT_PERMISSION'", f.permissionId()); + assertThat(queries.canAdminDocument(f.targetId(), f.documentId())).isFalse(); + assertThat(revoke(f)).isEqualTo(204); + assertThat(primaryPermissions.canReadDocument(f.ownerId(), f.documentId())).isTrue(); + new JdbcTemplate(dataSource).update("UPDATE documents SET visibility='PUBLIC' WHERE id=?", f.documentId()); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isTrue(); + event("authoritative-valid-paths", "valid_user_read=true forged_admin=false owner=true public=true"); + } + + @Test + @DisplayName("별도 컬렉션 USER 권한은 유지하지만 만료·문서 연결 제거 뒤에는 오래된 캐시를 거부한다") + void collectionProvenanceRemainsLive() { + Fixture f = fixture(); + assertThat(revoke(f)).isEqualTo(204); + Long collectionId = new TransactionTemplate(transactionManager).execute(status -> { + var owner = users.findById(f.ownerId()).orElseThrow(); + var collection = collections.save(com.opensource.docgrid.domain.collection.entity.DocumentCollection.builder() + .owner(owner).name("합성 컬렉션").visibility(VisibilityType.PRIVATE).build()); + collections.insertClosureForNewCollection(collection.getId(), null); + memberships.save(com.opensource.docgrid.domain.collection.entity.CollectionDocument.builder() + .collection(collection).document(documents.findById(f.documentId()).orElseThrow()) + .addedBy(owner).addedAt(LocalDateTime.now()).build()); + return collection.getId(); + }); + var grant = collectionCommands.grantPermission(collectionId, f.ownerId(), new GrantPermissionRequest( + PermissionTargetType.USER, f.targetId(), null, null, PermissionType.READ, null)); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isTrue(); + var jdbc = new JdbcTemplate(dataSource); + jdbc.update("UPDATE collection_permissions SET expires_at=CURRENT_TIMESTAMP - INTERVAL '1 day' WHERE id=?", grant.permissionId()); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isFalse(); + jdbc.update("UPDATE collection_permissions SET expires_at=NULL WHERE id=?", grant.permissionId()); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isTrue(); + jdbc.update("DELETE FROM collection_documents WHERE collection_id=? AND document_id=?", collectionId, f.documentId()); + assertThat(primaryPermissions.canReadDocument(f.targetId(), f.documentId())).isFalse(); + event("authoritative-collection", "live_collection=true expired=false detached=false"); + } + private Fixture fixture() { return new TransactionTemplate(transactionManager).execute(status -> { User owner = users.save(User.builder().email("owner-" + java.util.UUID.randomUUID() + "@example.invalid") diff --git a/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionStandbyIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionStandbyIntegrationTest.java new file mode 100644 index 00000000..4c0bf033 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/permission/integration/PermissionStandbyIntegrationTest.java @@ -0,0 +1,33 @@ +package com.opensource.docgrid.domain.permission.integration; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.OffsetDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import com.opensource.docgrid.domain.permission.service.query.PrimaryDocumentPermissionQueryService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** 실제 물리 standby에 잘못 도착한 최종 인가가 권한을 추정하지 않고 닫히는지 검증한다. */ +@Tag("permission-standby") +@SpringBootTest(classes = PermissionDurabilityIntegrationTest.Config.class, properties = { + "spring.datasource.url=jdbc:postgresql://127.0.0.1:55440/postgres?ApplicationName=permission-standby-boundary", + "spring.datasource.username=postgres", "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=false", + "permission.revocation.synchronous-enabled=true", "management.server.port=0", "logging.level.root=ERROR" +}) +class PermissionStandbyIntegrationTest { + @Autowired PrimaryDocumentPermissionQueryService permissions; + + @Test + @DisplayName("실제 standby에 도착한 최종 인가는 503으로 닫히고 권한 조회를 허용하지 않는다") + void rejectsActualStandby() { + assertThatThrownBy(() -> permissions.canReadDocument(-1L, -1L)) + .isInstanceOf(DocGridException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DOCUMENT_PERMISSION_UNAVAILABLE); + System.out.println("EVIDENCE " + OffsetDateTime.now() + " run=actual-standby authority_unavailable=503 content_returned=0"); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/integration/SearchTransactionBoundaryIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/search/integration/SearchTransactionBoundaryIntegrationTest.java index 7979a42b..1c7d2837 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/search/integration/SearchTransactionBoundaryIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/integration/SearchTransactionBoundaryIntegrationTest.java @@ -39,7 +39,7 @@ import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; import com.opensource.docgrid.domain.embedding.service.query.EmbeddingModelQueryService; 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.dto.SearchOutcome; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; import com.opensource.docgrid.domain.search.dto.request.SearchRequest; @@ -99,7 +99,7 @@ class SearchTransactionBoundaryIntegrationTest { // 하이브리드는 꺼진 상태(isEnabled=false)로 두어 이 테스트가 기존 벡터 검색 경로의 트랜잭션 경계를 그대로 검증한다. @MockitoBean private HybridSearchQueryService hybridSearchQueryService; @MockitoBean private HybridSearchProperties hybridSearchProperties; - @MockitoBean private PermissionQueryService permissionQueryService; + @MockitoBean private PrimaryDocumentPermissionQueryService permissionQueryService; private JdbcTemplate jdbcTemplate; private String marker; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java b/backend/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java index fa598359..39c09d17 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java @@ -27,7 +27,7 @@ import com.opensource.docgrid.domain.embedding.dto.EmbedResult; import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; 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.SearchAdmission; import com.opensource.docgrid.domain.search.dto.SearchOutcome; @@ -55,7 +55,7 @@ class SearchFacadeTest { @Mock private VectorSearchQueryService vectorSearchQueryService; @Mock private HybridSearchQueryService hybridSearchQueryService; @Mock private HybridSearchProperties hybridSearchProperties; - @Mock private PermissionQueryService permissionQueryService; + @Mock private PrimaryDocumentPermissionQueryService permissionQueryService; @Mock private SearchResultCommandService searchResultCommandService; private static final Long USER_ID = 1L; diff --git a/docs/test-results/permission-authoritative-read-20261017/README.md b/docs/test-results/permission-authoritative-read-20261017/README.md new file mode 100644 index 00000000..4073c187 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/README.md @@ -0,0 +1,42 @@ +# 최신 권한 원장 기반 최종 인가 검증 + +## 결과 + +로컬 PostgreSQL 17.8·물리 Standby 2대와 실제 Spring Controller·Service·JPA·Hikari에서 **계약 시험 10개 + 실제 standby 거부 1개, 총 11개가 통과**했습니다. 관련 기존 단위 회귀는 **93개, 실패 0개**입니다. + +| 실행 | 결과 | 원인·해석 | +| --- | --- | --- | +| [auth-run01](auth-run01.log) | 9개 중 1개 실패 | 시험 GET URL의 `/api` 누락을 수정 | +| [auth-run02](auth-run02.log) | 9개 중 1개 실패 | 시험 Accept 누락으로 XML 응답을 JSON으로 읽은 오류 수정 | +| [auth-run03](auth-run03.log) | 9개 실패·standby 미실행 | Docker internal 네트워크의 호스트 포트 비공개로 접속 실패. 루프백 게시 전용 bridge로 수정 | +| [auth-run04](auth-run04.log) | **10 + 1개 통과** | 신규·회귀·실제 standby 검증 | +| [auth-unit-run01](auth-unit-run01.log) · [집계](auth-unit-run01-summary.json) | **93개 통과** | 상세·검색·권한·Controller 기존 회귀 | + +- 회수 204 뒤 권한 원장 0건·오래된 직접 USER 캐시 1건을 주입: **상세·본문·파일 각각 403**, 새 검색 응답 **200·결과 0건·금지 내용 0건**. 파일 저장소는 호출되지 않았습니다. +- 검색 후보와 모델·검색 접수 원장은 제어 가능한 mock입니다. 실제 SearchController·SearchFacade의 최종 필터와 실제 Primary 권한 원장을 실행했으며 실제 Ollama·pgvector 검색 품질 시험이라고 주장하지 않습니다. +- 실제 물리 standby에 잘못 연결된 최종 인가는 **PERM-003·503**으로 닫히며 내용 반환 0건입니다. +- 현재 USER READ 원장은 허용, 캐시의 위조 ADMIN 비트는 거부, 소유자·PUBLIC은 허용했습니다. +- 다른 유효한 컬렉션 USER 원장은 허용했으며, 원장 만료나 문서 연결 제거 후 오래된 캐시는 거부했습니다. +- 동기 COMMIT 취소·WAL 재주장·같은 키 경합·네트워크 대기 제한 시험도 함께 통과했습니다. + +## 목적별 이벤트 + +- [회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다](auth-run04--baselineStaleCache.log) +- [동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다](auth-run04--commitTimeoutIsUnconfirmed.log) +- [응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다](auth-run04--baselineLostResponse.log) +- [권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다](auth-run04--unauthorizedRollback.log) +- [별도 컬렉션 USER 권한은 유지하지만 만료·문서 연결 제거 뒤에는 오래된 캐시를 거부한다](auth-run04--collectionProvenanceRemainsLive.log) +- [같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다](auth-run04--requestBindingConflict.log) +- [동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다](auth-run04--baselineCommitCancellation.log) +- [유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다](auth-run04--liveGrantAndAlternateAccessRemainValid.log) +- [동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다](auth-run04--missingSynchronousConfiguration.log) +- [같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다](auth-run04--concurrentReassertion.log) +- [실제 standby에 도착한 최종 인가는 503으로 닫히고 권한 조회를 허용하지 않는다](auth-run04--rejectsActualStandby.log) + +## 경계와 한계 + +공개 HTTP 인증·실제 모델·GCS·운영 OpenProxy·자동 승격은 이 실행에 포함하지 않았습니다. 회수 응답 뒤 시작하는 새 상세·본문·파일·검색 응답을 대상으로 하며, 과거 대화/RAG 조회·이미 진행 중인 요청·문서 목록의 후보 필터는 범위 밖입니다. + +최종 인가 wrapper는 read-write Transaction에서 Primary 역할을 먼저 확인합니다. 상세·본문·파일 Snapshot 등의 메서드도 read-write로 명시해 동일 연결을 사용합니다. 권한 조회 전용 wrapper가 기존 외부 read-only Transaction 안에 잘못 사용돼 standby로 라우팅되면 접근을 허용하지 않습니다. 캐시는 권한 출처의 현재 대상·접근 비트·만료·컬렉션 연결까지 교차 검증합니다. + +재실행은 `permission_isolated_fixture.py up` → `permissionStandbyTest` → `permission_isolated_fixture.py down`입니다. Standby 시험 task는 먼저 Primary 계약 시험을 실행해 스키마를 준비합니다. 모든 게시 포트는 루프백이며 실제 인프라 식별자·인증 정보는 증거에 포함하지 않습니다. diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run01.log b/docs/test-results/permission-authoritative-read-20261017/auth-run01.log new file mode 100644 index 00000000..528f1872 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run01.log @@ -0,0 +1,28 @@ +실행 ID: auth-run01 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA +목적: 회수 후 오래된 캐시가 있어도 최종 응답 거부 +명령: ./backend/gradlew -p backend permissionDurabilityTest +성공 기준: 상세·본문·파일 403, 새 검색 금지 내용 0건 +PermissionDurabilityIntegrationTest > 회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다 FAILED +EVIDENCE 2026-10-17T05:13:05.367774+09:00 run=sync-timeout http=503 elapsed_ms=5040 +PermissionDurabilityIntegrationTest > 동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다 PASSED +EVIDENCE 2026-10-17T05:13:06.302432+09:00 run=sync-response-loss first_response_discarded=true retry_http=204 wal_assertions=2 +PermissionDurabilityIntegrationTest > 응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다 PASSED +EVIDENCE 2026-10-17T05:13:06.885024+09:00 run=sync-atomic-denial http=403 permission_rows=1 receipt_rows=0 +PermissionDurabilityIntegrationTest > 권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다 PASSED +EVIDENCE 2026-10-17T05:13:07.513320+09:00 run=sync-key-binding http=409 new_permission_rows=1 +PermissionDurabilityIntegrationTest > 같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다 PASSED +EVIDENCE 2026-10-17T05:13:08.383331+09:00 run=cancel wait_event=SyncRep +EVIDENCE 2026-10-17T05:13:08.388724+09:00 run=commit-warning sqlstate=01000 +EVIDENCE 2026-10-17T05:13:08.393949+09:00 run=sync-cancel http=503 local_permission_rows=0 warnings=1 +EVIDENCE 2026-10-17T05:13:08.690215+09:00 run=sync-reassert retry_http=204 receipt_rows=1 wal_assertions=2 +PermissionDurabilityIntegrationTest > 동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다 PASSED +EVIDENCE 2026-10-17T05:13:09.593104+09:00 run=authoritative-valid-paths valid_user_read=true forged_admin=false owner=true public=true +PermissionDurabilityIntegrationTest > 유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다 PASSED +EVIDENCE 2026-10-17T05:13:10.452362+09:00 run=sync-not-configured http=503 permission_rows=1 receipt_rows=0 +PermissionDurabilityIntegrationTest > 동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다 PASSED +EVIDENCE 2026-10-17T05:13:11.073365+09:00 run=sync-concurrency http_204=2 receipt_rows=1 wal_assertions=2 pooled_setting=local +PermissionDurabilityIntegrationTest > 같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다 PASSED +9 tests completed, 1 failed +> Task :permissionDurabilityTest FAILED +BUILD FAILED in 17s diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run02.log b/docs/test-results/permission-authoritative-read-20261017/auth-run02.log new file mode 100644 index 00000000..a6edb906 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run02.log @@ -0,0 +1,28 @@ +실행 ID: auth-run02 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA +목적: 회수 후 오래된 캐시가 있어도 최종 응답 거부 +명령: ./backend/gradlew -p backend permissionDurabilityTest +성공 기준: 상세·본문·파일 403, 새 검색 금지 내용 0건 +PermissionDurabilityIntegrationTest > 회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다 FAILED +EVIDENCE 2026-10-17T05:14:50.478479+09:00 run=sync-timeout http=503 elapsed_ms=5027 +PermissionDurabilityIntegrationTest > 동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다 PASSED +EVIDENCE 2026-10-17T05:14:51.412059+09:00 run=sync-response-loss first_response_discarded=true retry_http=204 wal_assertions=2 +PermissionDurabilityIntegrationTest > 응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다 PASSED +EVIDENCE 2026-10-17T05:14:52.011999+09:00 run=sync-atomic-denial http=403 permission_rows=1 receipt_rows=0 +PermissionDurabilityIntegrationTest > 권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다 PASSED +EVIDENCE 2026-10-17T05:14:52.607119+09:00 run=sync-key-binding http=409 new_permission_rows=1 +PermissionDurabilityIntegrationTest > 같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다 PASSED +EVIDENCE 2026-10-17T05:14:53.503245+09:00 run=cancel wait_event=SyncRep +EVIDENCE 2026-10-17T05:14:53.508574+09:00 run=commit-warning sqlstate=01000 +EVIDENCE 2026-10-17T05:14:53.512822+09:00 run=sync-cancel http=503 local_permission_rows=0 warnings=1 +EVIDENCE 2026-10-17T05:14:53.810847+09:00 run=sync-reassert retry_http=204 receipt_rows=1 wal_assertions=2 +PermissionDurabilityIntegrationTest > 동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다 PASSED +EVIDENCE 2026-10-17T05:14:54.712276+09:00 run=authoritative-valid-paths valid_user_read=true forged_admin=false owner=true public=true +PermissionDurabilityIntegrationTest > 유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다 PASSED +EVIDENCE 2026-10-17T05:14:55.574811+09:00 run=sync-not-configured http=503 permission_rows=1 receipt_rows=0 +PermissionDurabilityIntegrationTest > 동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다 PASSED +EVIDENCE 2026-10-17T05:14:56.191239+09:00 run=sync-concurrency http_204=2 receipt_rows=1 wal_assertions=2 pooled_setting=local +PermissionDurabilityIntegrationTest > 같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다 PASSED +9 tests completed, 1 failed +> Task :permissionDurabilityTest FAILED +BUILD FAILED in 18s diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run03.log b/docs/test-results/permission-authoritative-read-20261017/auth-run03.log new file mode 100644 index 00000000..66dfbacc --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run03.log @@ -0,0 +1,17 @@ +실행 ID: auth-run03 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA +목적: 회수 후 오래된 캐시가 있어도 최종 응답 거부 +명령: ./backend/gradlew -p backend permissionDurabilityTest +성공 기준: 상세·본문·파일 403, 새 검색 금지 내용 0건 +PermissionDurabilityIntegrationTest > 회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다 FAILED +PermissionDurabilityIntegrationTest > 동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다 FAILED +PermissionDurabilityIntegrationTest > 응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다 FAILED +PermissionDurabilityIntegrationTest > 권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다 FAILED +PermissionDurabilityIntegrationTest > 같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다 FAILED +PermissionDurabilityIntegrationTest > 동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다 FAILED +PermissionDurabilityIntegrationTest > 유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다 FAILED +PermissionDurabilityIntegrationTest > 동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다 FAILED +PermissionDurabilityIntegrationTest > 같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다 FAILED +9 tests completed, 9 failed +> Task :permissionDurabilityTest FAILED +BUILD FAILED in 4s diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineCommitCancellation.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineCommitCancellation.log new file mode 100644 index 00000000..2a170667 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineCommitCancellation.log @@ -0,0 +1,13 @@ +실행 ID: auth-run04 / 동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다 +목적: 동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 1.496초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:47.742595+09:00 run=cancel wait_event=SyncRep +EVIDENCE 2026-10-17T05:21:47.747065+09:00 run=commit-warning sqlstate=01000 +EVIDENCE 2026-10-17T05:21:47.751088+09:00 run=sync-cancel http=503 local_permission_rows=0 warnings=1 +EVIDENCE 2026-10-17T05:21:48.046561+09:00 run=sync-reassert retry_http=204 receipt_rows=1 wal_assertions=2 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineLostResponse.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineLostResponse.log new file mode 100644 index 00000000..5297321f --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineLostResponse.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다 +목적: 응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.653초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:44.949323+09:00 run=sync-response-loss first_response_discarded=true retry_http=204 wal_assertions=2 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineStaleCache.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineStaleCache.log new file mode 100644 index 00000000..1ddda232 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--baselineStaleCache.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다 +목적: 회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 1.488초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:38.116638+09:00 run=authoritative-stale-cache permission_rows=0 stale_cache=1 detail_403=1 content_403=1 file_403=1 search_results=0 forbidden_content=0 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--collectionProvenanceRemainsLive.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--collectionProvenanceRemainsLive.log new file mode 100644 index 00000000..27ca63a7 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--collectionProvenanceRemainsLive.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 별도 컬렉션 USER 권한은 유지하지만 만료·문서 연결 제거 뒤에는 오래된 캐시를 거부한다 +목적: 별도 컬렉션 USER 권한은 유지하지만 만료·문서 연결 제거 뒤에는 오래된 캐시를 거부한다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.658초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:46.193532+09:00 run=authoritative-collection live_collection=true expired=false detached=false diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--commitTimeoutIsUnconfirmed.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--commitTimeoutIsUnconfirmed.log new file mode 100644 index 00000000..cadafb5c --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--commitTimeoutIsUnconfirmed.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다 +목적: 동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 6.161초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:43.994849+09:00 run=sync-timeout http=503 elapsed_ms=5043 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--concurrentReassertion.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--concurrentReassertion.log new file mode 100644 index 00000000..25875e9d --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--concurrentReassertion.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다 +목적: 같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.6초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:50.419594+09:00 run=sync-concurrency http_204=2 receipt_rows=1 wal_assertions=2 pooled_setting=local diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--liveGrantAndAlternateAccessRemainValid.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--liveGrantAndAlternateAccessRemainValid.log new file mode 100644 index 00000000..7ceffa25 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--liveGrantAndAlternateAccessRemainValid.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다 +목적: 유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.619초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:48.952133+09:00 run=authoritative-valid-paths valid_user_read=true forged_admin=false owner=true public=true diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--missingSynchronousConfiguration.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--missingSynchronousConfiguration.log new file mode 100644 index 00000000..4d9e0fb7 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--missingSynchronousConfiguration.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다 +목적: 동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.862초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:49.814468+09:00 run=sync-not-configured http=503 permission_rows=1 receipt_rows=0 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--rejectsActualStandby.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--rejectsActualStandby.log new file mode 100644 index 00000000..3ca6849e --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--rejectsActualStandby.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 실제 standby에 도착한 최종 인가는 503으로 닫히고 권한 조회를 허용하지 않는다 +목적: 실제 standby에 도착한 최종 인가는 503으로 닫히고 권한 조회를 허용하지 않는다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.257초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:54.389373+09:00 run=actual-standby authority_unavailable=503 content_returned=0 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--requestBindingConflict.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--requestBindingConflict.log new file mode 100644 index 00000000..95a9ab56 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--requestBindingConflict.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다 +목적: 같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.637초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:46.820705+09:00 run=sync-key-binding http=409 new_permission_rows=1 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04--unauthorizedRollback.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04--unauthorizedRollback.log new file mode 100644 index 00000000..a9cccbf9 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04--unauthorizedRollback.log @@ -0,0 +1,10 @@ +실행 ID: auth-run04 / 권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다 +목적: 권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다 +위치: 로컬 PostgreSQL 17.8 / 실제 MVC·JPA·Hikari +명령: ./backend/gradlew -p backend permissionStandbyTest +관측 소요 시간: 0.595초 +결과: 통과 +원본: auth-run04.log 및 해당 실행 JUnit 이벤트의 목적별 분리본 + +목적별 이벤트는 전체 실행 원본에서 run 키로 분리했습니다. +EVIDENCE 2026-10-17T05:21:45.533063+09:00 run=sync-atomic-denial http=403 permission_rows=1 receipt_rows=0 diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-run04.log b/docs/test-results/permission-authoritative-read-20261017/auth-run04.log new file mode 100644 index 00000000..f357303d --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-run04.log @@ -0,0 +1,38 @@ +{"실행_ID": "auth-run04", "목적": "회수 후 새 MVC 요청 4종·캐시 출처·실제 standby 인가 거부", "시작_KST": "2026-10-17T05:21:31.385209+09:00", "위치": "로컬 격리 PostgreSQL 17.8 / JVM", "명령": "./backend/gradlew -p backend permissionStandbyTest --console=plain", "성공_기준": "실제 커밋 경계·원장·인가 단언 전부 통과; 실패·미실행을 구분"} +> Task :compileJava UP-TO-DATE +> Task :processResources UP-TO-DATE +> Task :classes UP-TO-DATE +> Task :compileTestJava +> Task :processTestResources UP-TO-DATE +> Task :testClasses +> Task :permissionDurabilityTest +> Task :permissionDurabilityTest +EVIDENCE 2026-10-17T05:21:38.116638+09:00 run=authoritative-stale-cache permission_rows=0 stale_cache=1 detail_403=1 content_403=1 file_403=1 search_results=0 forbidden_content=0 +PermissionDurabilityIntegrationTest > 회수 뒤 오래된 USER 캐시는 상세·본문·파일·새 검색의 최종 응답을 허용하지 않는다 PASSED +EVIDENCE 2026-10-17T05:21:43.994849+09:00 run=sync-timeout http=503 elapsed_ms=5043 +PermissionDurabilityIntegrationTest > 동기 후보 전체 부재의 COMMIT 대기는 5초 제한 뒤 결과 불명으로 끝난다 PASSED +EVIDENCE 2026-10-17T05:21:44.949323+09:00 run=sync-response-loss first_response_discarded=true retry_http=204 wal_assertions=2 +PermissionDurabilityIntegrationTest > 응답 유실 뒤 같은 키 회수는 새 WAL로 204를 다시 확정한다 PASSED +EVIDENCE 2026-10-17T05:21:45.533063+09:00 run=sync-atomic-denial http=403 permission_rows=1 receipt_rows=0 +PermissionDurabilityIntegrationTest > 권한 없는 대상자의 회수는 403이며 요청 원장 INSERT도 롤백된다 PASSED +EVIDENCE 2026-10-17T05:21:46.193532+09:00 run=authoritative-collection live_collection=true expired=false detached=false +PermissionDurabilityIntegrationTest > 별도 컬렉션 USER 권한은 유지하지만 만료·문서 연결 제거 뒤에는 오래된 캐시를 거부한다 PASSED +EVIDENCE 2026-10-17T05:21:46.820705+09:00 run=sync-key-binding http=409 new_permission_rows=1 +PermissionDurabilityIntegrationTest > 같은 요청 키를 다른 권한에 사용하면 409이며 새 권한은 유지된다 PASSED +EVIDENCE 2026-10-17T05:21:47.742595+09:00 run=cancel wait_event=SyncRep +EVIDENCE 2026-10-17T05:21:47.747065+09:00 run=commit-warning sqlstate=01000 +EVIDENCE 2026-10-17T05:21:47.751088+09:00 run=sync-cancel http=503 local_permission_rows=0 warnings=1 +EVIDENCE 2026-10-17T05:21:48.046561+09:00 run=sync-reassert retry_http=204 receipt_rows=1 wal_assertions=2 +PermissionDurabilityIntegrationTest > 동기 COMMIT 취소는 503이며 같은 키 WAL 재주장으로 204를 재확정한다 PASSED +EVIDENCE 2026-10-17T05:21:48.952133+09:00 run=authoritative-valid-paths valid_user_read=true forged_admin=false owner=true public=true +PermissionDurabilityIntegrationTest > 유효한 원장과 PUBLIC·소유자 접근은 유지하고 캐시의 위조된 관리 비트는 거부한다 PASSED +EVIDENCE 2026-10-17T05:21:49.814468+09:00 run=sync-not-configured http=503 permission_rows=1 receipt_rows=0 +PermissionDurabilityIntegrationTest > 동기 후보 설정이 없으면 503이며 권한과 요청 원장을 바꾸지 않는다 PASSED +EVIDENCE 2026-10-17T05:21:50.419594+09:00 run=sync-concurrency http_204=2 receipt_rows=1 wal_assertions=2 pooled_setting=local +PermissionDurabilityIntegrationTest > 같은 키의 동시 회수는 행 잠금으로 직렬화되고 요청 원장 하나로 수렴한다 PASSED +> Task :permissionStandbyTest +> Task :permissionStandbyTest +EVIDENCE 2026-10-17T05:21:54.389373+09:00 run=actual-standby authority_unavailable=503 content_returned=0 +PermissionStandbyIntegrationTest > 실제 standby에 도착한 최종 인가는 503으로 닫히고 권한 조회를 허용하지 않는다 PASSED +BUILD SUCCESSFUL in 23s +{"종료_KST": "2026-10-17T05:21:54.475189+09:00", "exit_code": 0} diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-unit-run01-summary.json b/docs/test-results/permission-authoritative-read-20261017/auth-unit-run01-summary.json new file mode 100644 index 00000000..2654ad59 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-unit-run01-summary.json @@ -0,0 +1,7 @@ +{ + "run_id": "auth-unit-run01", + "tests": 93, + "failures": 0, + "errors": 0, + "skipped": 0 +} diff --git a/docs/test-results/permission-authoritative-read-20261017/auth-unit-run01.log b/docs/test-results/permission-authoritative-read-20261017/auth-unit-run01.log new file mode 100644 index 00000000..c4b7bdc4 --- /dev/null +++ b/docs/test-results/permission-authoritative-read-20261017/auth-unit-run01.log @@ -0,0 +1,10 @@ +{"실행_ID": "auth-unit-run01", "목적": "상세·검색·권한·Controller 회귀", "시작_KST": "2026-10-17T05:22:45.738015+09:00", "위치": "로컬 격리 PostgreSQL 17.8 / JVM", "명령": "./backend/gradlew -p backend test --tests *DocumentQueryServiceTest --tests *SearchFacadeTest --tests *PermissionQueryServiceTest --tests *PermissionControllerTest --console=plain", "성공_기준": "실제 커밋 경계·원장·인가 단언 전부 통과; 실패·미실행을 구분"} +> Task :compileJava UP-TO-DATE +> Task :processResources UP-TO-DATE +> Task :classes UP-TO-DATE +> Task :compileTestJava UP-TO-DATE +> Task :processTestResources UP-TO-DATE +> Task :testClasses UP-TO-DATE +> Task :test +BUILD SUCCESSFUL in 3s +{"종료_KST": "2026-10-17T05:22:49.516667+09:00", "exit_code": 0} diff --git a/scripts/opensql/permission_isolated_fixture.py b/scripts/opensql/permission_isolated_fixture.py index d3f5fcfc..48539e16 100644 --- a/scripts/opensql/permission_isolated_fixture.py +++ b/scripts/opensql/permission_isolated_fixture.py @@ -10,7 +10,7 @@ def d(*a): def sql(node,q): return d('exec',N+'-'+node,'psql','-h','127.0.0.1','-U','postgres','-d','postgres','-At','-c',q) if sys.argv[1]=='up': - d('network','create','--internal',N) + d('network','create',N) d('run','-d','--label','docgrid.permission.fixture=true','--name',N+'-primary','--network',N,'-p','127.0.0.1:55439:5432','-e','POSTGRES_HOST_AUTH_METHOD=trust',I,'postgres','-c','wal_level=replica','-c','max_wal_senders=10','-c','max_replication_slots=10','-c','wal_keep_size=128MB') for _ in range(60): try: @@ -22,7 +22,7 @@ def sql(node,q): sql('primary','select pg_reload_conf()') for i in (1,2): name=N+'-standby'+str(i) - d('run','-d','--label','docgrid.permission.fixture=true','--name',name,'--network',N,'-e','POSTGRES_HOST_AUTH_METHOD=trust','--entrypoint','bash',I,'-ec',"mkdir -p /var/lib/postgresql/data; chown postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data; gosu postgres pg_basebackup -h "+N+"-primary -U postgres -D /var/lib/postgresql/data -Fp -Xs -R; echo \"primary_conninfo = 'host="+N+"-primary user=postgres application_name=standby"+str(i)+"'\" >> /var/lib/postgresql/data/postgresql.auto.conf; exec gosu postgres postgres") + d('run','-d','--label','docgrid.permission.fixture=true','--name',name,'--network',N,'-p','127.0.0.1:'+str(55439+i)+':5432','-e','POSTGRES_HOST_AUTH_METHOD=trust','--entrypoint','bash',I,'-ec',"mkdir -p /var/lib/postgresql/data; chown postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data; gosu postgres pg_basebackup -h "+N+"-primary -U postgres -D /var/lib/postgresql/data -Fp -Xs -R; echo \"primary_conninfo = 'host="+N+"-primary user=postgres application_name=standby"+str(i)+"'\" >> /var/lib/postgresql/data/postgresql.auto.conf; exec gosu postgres postgres") for _ in range(60): if sql('primary',"select count(*) from pg_stat_replication where state='streaming'")=='2': break time.sleep(.5)