From 88daf83cdacfb5af196604c93c4699036ba7f917 Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 01:33:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=84=EB=B2=A0=EB=94=A9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9D=84=20=EC=9A=94=EC=B2=AD=EC=9E=90=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=20=EB=AC=B8=EC=84=9C=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/internal/embeddings/search 에는 userId 파라미터가 아예 없었다. documentIds 가 비면 스펙상 "전체 검색" 이라 다른 사용자의 청크까지 대상이고, id 를 줘도 소유권을 확인하지 않아 남의 문서 id 를 넣으면 그대로 조회됐다. 실제 유출은 없었다 — AI 호출부 3곳이 모두 빈 목록을 사전에 걸러 (none) 을 반환한다. 하지만 방어가 전적으로 호출자에게 있었다. 호출부가 하나 늘거나 가드를 빠뜨리면 남의 이력서 청크가 프롬프트로 들어간다. Core 가 스코프를 확정한다: - userId 필수(@NotNull) - documentIds 를 주면 소유 문서와의 교집합만 — 요청한 id 를 그대로 믿지 않는다 - 비면 그 사용자의 활성 문서 전체 ("비면 전체 사용자" 규약 폐기) - 교집합이 비면 검색하지 않고 빈 결과 (빈 목록을 넘기면 다시 전체 검색이 된다) AI 는 envelope.context.user_id 를 싣는다 — Core 가 generate.questions/ followup/feedback 발행 시 이미 채우고 있어서 메시지 계약 변경이 없다. user_id 를 못 얻으면 검색을 건너뛰고 (none) 으로 폴백한다. --- ai/src/ai_server/core/client.py | 8 +++- .../messaging/consumers/feedback_consumer.py | 29 +++++++++--- .../messaging/consumers/followup_consumer.py | 17 +++++-- .../messaging/consumers/questions_consumer.py | 13 +++-- ai/tests/test_core_client.py | 8 +++- ai/tests/test_followup_consumer.py | 4 +- backend/openapi.json | 10 ++-- .../application/DocumentEmbeddingService.java | 23 ++++++++- .../domain/AnalyzedDocumentRepository.java | 14 ++++++ .../InternalEmbeddingSearchController.java | 8 +++- .../DocumentEmbeddingSearchTest.java | 47 ++++++++++++++++++- docs/messaging.md | 16 ++++++- frontend/src/shared/api/generated.ts | 6 ++- 13 files changed, 172 insertions(+), 31 deletions(-) diff --git a/ai/src/ai_server/core/client.py b/ai/src/ai_server/core/client.py index b97f8dab..511fc3ff 100644 --- a/ai/src/ai_server/core/client.py +++ b/ai/src/ai_server/core/client.py @@ -56,6 +56,7 @@ async def upsert_embeddings( async def search_embeddings( self, *, + user_id: int, query_embedding: list[float], query_text: str | None = None, document_ids: list[int] | None = None, @@ -254,14 +255,19 @@ async def _do_upsert( async def search_embeddings( self, *, + user_id: int, query_embedding: list[float], query_text: str | None = None, document_ids: list[int] | None = None, top_k: int = 5, ) -> list[EmbeddingSearchHit]: """임베딩 검색. query_text 가 주어지면 Core 가 벡터+BM25 RRF 하이브리드로, - 없으면 pgvector cosine 단독으로 topK 반환. 실패 시 빈 리스트 (RAG 보강용이므로 fatal 아님).""" + 없으면 pgvector cosine 단독으로 topK 반환. 실패 시 빈 리스트 (RAG 보강용이므로 fatal 아님). + + user_id 는 필수 — Core 가 검색 범위를 이 사용자 소유 문서로 제한한다. + envelope.context.user_id 를 그대로 넘긴다.""" body: dict = { + "userId": user_id, "queryEmbedding": query_embedding, "documentIds": list(document_ids or []), "topK": top_k, diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 3c7b534b..7174b225 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -149,7 +149,8 @@ async def _process( ) transcript = _build_transcript(req.messages) score_basis = _build_score_basis(req.messages) - rag_context = await self._build_rag_context(req) + user_id = envelope.context.user_id + rag_context = await self._build_rag_context(req, user_id) voice_analysis_summary = _build_voice_analysis_summary( req.voice_analysis_summary ) @@ -208,7 +209,7 @@ async def _tracked(coro: Awaitable[T]) -> T: _tracked(self._evaluate_self_intro(req, voice_analysis_summary)), _tracked(self._evaluate_job_fit(req, transcript, rag_context)), _tracked(self._evaluate_personality(req)), - _tracked(self._coach_answers(req)), + _tracked(self._coach_answers(req, user_id)), ) # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지. extras = [self_intro_item, *job_fit_items, personality_item] @@ -475,7 +476,7 @@ async def _evaluate_personality( return _to_panel_item(PERSONALITY_EVALUATOR_LABEL, PERSONALITY_DIMENSION, ev) async def _coach_answers( - self, req: GenerateFeedbackRequest + self, req: GenerateFeedbackRequest, user_id: int | None ) -> list[AnswerCoachingItem]: """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트. @@ -510,6 +511,7 @@ async def _one( req.context_document_ids, _COACHING_RAG_TOP_K, req.session_id, + user_id, ) res = await self._answer_coach.coach( job_category=req.job_category, @@ -538,25 +540,38 @@ async def _one( items = await asyncio.gather(*(_one(q, a) for q, a in pairs)) return [it for it in items if it is not None] - async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: + async def _build_rag_context( + self, req: GenerateFeedbackRequest, user_id: int | None + ) -> str: # 세션 전체 채점(종합·패널·직무 적합도)용 컨텍스트. 마지막 답변 하나만 쓰면 # 그 화제로 근거가 편향되므로, 세션의 모든 실질 답변(짧은 확인 제외)을 질의로 삼는다. query = _session_rag_query(req.messages) return await self._retrieve_context( - query, req.context_document_ids, self._rag_top_k, req.session_id + query, req.context_document_ids, self._rag_top_k, req.session_id, user_id ) async def _retrieve_context( - self, query_text: str, document_ids: list[int], top_k: int, session_id: int + self, + query_text: str, + document_ids: list[int], + top_k: int, + session_id: int, + user_id: int | None, ) -> str: - """query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'.""" + """query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'. + + user_id 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 없으면 검색하지 않는다.""" if not self._embedder or not document_ids or not query_text.strip(): return "(none)" + if user_id is None: + log.warning("feedback.rag.skipped_no_user", session_id=session_id) + return "(none)" try: query_vec = ( await self._embedder.embed([query_text], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( + user_id=user_id, query_embedding=query_vec, query_text=query_text, document_ids=document_ids, diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py index e5a1ac2e..95998968 100644 --- a/ai/src/ai_server/messaging/consumers/followup_consumer.py +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -95,7 +95,7 @@ async def _process( trace_id=envelope.trace_id, ) - rag_context = await self._build_rag_context(req) + rag_context = await self._build_rag_context(req, envelope.context.user_id) if self._streaming is not None and self._notifier is not None: result = await self._stream_followup(req, rag_context, envelope.trace_id) else: @@ -239,12 +239,18 @@ async def _synth_segment( error=str(exc), ) - async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: + async def _build_rag_context( + self, req: GenerateFollowupRequest, user_id: int | None + ) -> str: + # user_id 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 없으면 검색하지 않는다. if not self._core or not self._embedder or not req.context_document_ids: return "(none)" + if user_id is None: + log.warning("followup.rag.skipped_no_user", session_id=req.session_id) + return "(none)" try: return await asyncio.wait_for( - self._do_build_rag_context(req), timeout=self._rag_timeout_sec + self._do_build_rag_context(req, user_id), timeout=self._rag_timeout_sec ) except asyncio.TimeoutError: log.warning( @@ -254,13 +260,16 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: ) return "(none)" - async def _do_build_rag_context(self, req: GenerateFollowupRequest) -> str: + async def _do_build_rag_context( + self, req: GenerateFollowupRequest, user_id: int + ) -> str: query = f"{req.previous_question}\n\n{req.answer_text}" try: query_vec = ( await self._embedder.embed([query], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( + user_id=user_id, query_embedding=query_vec, query_text=query, document_ids=req.context_document_ids, diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 294df353..dbaa87e0 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -98,7 +98,7 @@ async def _process( message="면접 자료를 정리하고 있어요.", trace_id=envelope.trace_id, ) - context_text = await self._build_context(req) + context_text = await self._build_context(req, envelope.context.user_id) await self._emit_progress( session_id=req.session_id, phase="GENERATING", @@ -160,9 +160,12 @@ async def _emit_progress( trace_id=trace_id, ) - async def _build_context(self, req: GenerateQuestionsRequest) -> str: + async def _build_context( + self, req: GenerateQuestionsRequest, user_id: int | None + ) -> str: base_context = _build_context(req.documents) - if not self._core or not self._embedder: + # user_id 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 없으면 검색하지 않는다. + if not self._core or not self._embedder or user_id is None: return base_context document_ids = [d.document_id for d in req.documents] @@ -173,7 +176,7 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: try: return await asyncio.wait_for( - self._do_build_context_rag(req, document_ids, base_context), + self._do_build_context_rag(req, document_ids, base_context, user_id), timeout=self._rag_timeout_sec, ) except asyncio.TimeoutError: @@ -189,6 +192,7 @@ async def _do_build_context_rag( req: GenerateQuestionsRequest, document_ids: list[int], base_context: str, + user_id: int, ) -> str: query = _build_initial_rag_query(req) try: @@ -196,6 +200,7 @@ async def _do_build_context_rag( await self._embedder.embed([query], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( + user_id=user_id, query_embedding=query_vec, query_text=query, document_ids=document_ids, diff --git a/ai/tests/test_core_client.py b/ai/tests/test_core_client.py index 02977b2e..54eae1b4 100644 --- a/ai/tests/test_core_client.py +++ b/ai/tests/test_core_client.py @@ -287,6 +287,7 @@ async def test_search_embeddings_uses_latest_core_contract() -> None: core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) hits = await core.search_embeddings( + user_id=7, query_embedding=[0.1, 0.2], document_ids=[7, 8], top_k=3, @@ -303,6 +304,8 @@ async def test_search_embeddings_uses_latest_core_contract() -> None: client.post.assert_awaited_once_with( "/api/internal/embeddings/search", json={ + # userId 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 빠지면 400. + "userId": 7, "queryEmbedding": [0.1, 0.2], "documentIds": [7, 8], "topK": 3, @@ -316,6 +319,7 @@ async def test_search_embeddings_includes_query_text_for_hybrid() -> None: core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) await core.search_embeddings( + user_id=7, query_embedding=[0.1], query_text="gRPC 동시성 처리", document_ids=[7], @@ -333,7 +337,7 @@ async def test_search_embeddings_non_2xx_returns_empty(status: int) -> None: client = _make_post_client(status=status, text="bad") core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) - assert await core.search_embeddings(query_embedding=[0.1]) == [] + assert await core.search_embeddings(user_id=7, query_embedding=[0.1]) == [] @pytest.mark.asyncio @@ -341,4 +345,4 @@ async def test_search_embeddings_http_error_returns_empty() -> None: client = _make_post_client(raise_exc=httpx.ConnectError("dns fail")) core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) - assert await core.search_embeddings(query_embedding=[0.1]) == [] + assert await core.search_embeddings(user_id=7, query_embedding=[0.1]) == [] diff --git a/ai/tests/test_followup_consumer.py b/ai/tests/test_followup_consumer.py index 8f67756b..97986ddd 100644 --- a/ai/tests/test_followup_consumer.py +++ b/ai/tests/test_followup_consumer.py @@ -687,7 +687,7 @@ async def test_rag_searches_top_k_directly(): ) req = _make_req() - result = await consumer._build_rag_context(req) + result = await consumer._build_rag_context(req, 7) # 청크 텍스트가 결과에 포함돼야 한다 assert "이 청크가 반환돼야 한다" in result @@ -731,6 +731,6 @@ async def _slow_embed(texts, *, task_type=""): ) req = _make_req() - result = await consumer._build_rag_context(req) + result = await consumer._build_rag_context(req, 7) assert result == "(none)" diff --git a/backend/openapi.json b/backend/openapi.json index ee5af73a..25e0dfcb 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1307,7 +1307,7 @@ "post" : { "tags" : [ "Internal: Embedding Search" ], "summary" : "pgvector cosine topK 검색", - "description" : "queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 가 비어 있으면 전체.", + "description" : "queryEmbedding 으로 가장 가까운 청크 topK 반환. **검색 범위는 항상 userId 소유 문서로 제한**된다 — documentIds 를 주면 그 중 소유한 것만, 비어 있으면 소유 문서 전체.", "operationId" : "internalSearchEmbeddings", "requestBody" : { "content" : { @@ -1331,7 +1331,7 @@ } }, "400" : { - "description" : "queryEmbedding 누락", + "description" : "userId 또는 queryEmbedding 누락", "content" : { "*/*" : { "schema" : { @@ -3695,6 +3695,10 @@ "SearchRequest" : { "type" : "object", "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + }, "queryEmbedding" : { "type" : "array", "items" : { @@ -3717,7 +3721,7 @@ "format" : "int32" } }, - "required" : [ "queryEmbedding" ] + "required" : [ "queryEmbedding", "userId" ] }, "SearchResponse" : { "type" : "object", diff --git a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java index 606c1a8c..996efecd 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java @@ -30,9 +30,28 @@ public int upsert(EmbeddingUpsertCommand command) { return embeddingRepository.upsertAll(command.documentId(), command.model(), mapped); } + /** + * 임베딩 검색 — **항상 요청자 소유 문서로 제한한다.** + * + *

이전에는 documentIds 가 비면 전체 사용자의 청크가 대상이었고 소유권 검증도 없었다. + * 유출이 없었던 건 AI 호출부 3곳이 모두 빈 목록을 사전에 걸러줬기 때문인데, 방어가 + * 전적으로 호출자에게 있었다 — 호출부가 하나 늘거나 가드를 빠뜨리면 남의 이력서 청크가 + * 프롬프트로 들어간다. 여기서 스코프를 확정해 호출자와 무관하게 불가능하게 만든다. + * + *

documentIds 를 주면 그 중 소유한 것만 남기고(요청한 id 를 그대로 믿지 않는다), + * 비어 있으면 소유 문서 전체가 대상이다. 교집합이 비면 검색하지 않고 빈 결과 — + * 빈 목록을 그대로 넘기면 다시 전체 검색이 된다. + */ public List search( - float[] queryEmbedding, String queryText, List documentIds, int topK + Long userId, float[] queryEmbedding, String queryText, List documentIds, int topK ) { - return embeddingRepository.search(queryEmbedding, queryText, documentIds, topK); + List owned = documentRepository.findActiveIdsByOwner(userId); + List scoped = documentIds == null || documentIds.isEmpty() + ? owned + : documentIds.stream().filter(owned::contains).toList(); + if (scoped.isEmpty()) { + return List.of(); + } + return embeddingRepository.search(queryEmbedding, queryText, scoped, topK); } } diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java index 08c1c312..9eae3c16 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java @@ -46,6 +46,20 @@ Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id( """) Optional findActiveByIdAndOwner(@Param("id") Long id, @Param("userId") Long userId); + // 임베딩 검색 스코프 확정용 — 엔티티가 아니라 id 만 필요하다. + @Query(""" + SELECT d.id FROM AnalyzedDocument d + LEFT JOIN d.resume rs + LEFT JOIN rs.user ru + LEFT JOIN d.repository rp + LEFT JOIN rp.user pu + LEFT JOIN d.coverLetter cl + LEFT JOIN cl.user cu + WHERE d.deleted = false + AND (ru.id = :userId OR pu.id = :userId OR cu.id = :userId) + """) + List findActiveIdsByOwner(@Param("userId") Long userId); + @Query(""" SELECT d FROM AnalyzedDocument d WHERE d.coverLetter.id = :coverLetterId diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java index 6835b27c..5fc26f27 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java @@ -28,16 +28,17 @@ public class InternalEmbeddingSearchController { @Operation( operationId = "internalSearchEmbeddings", summary = "pgvector cosine topK 검색", - description = "queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 가 비어 있으면 전체." + description = "queryEmbedding 으로 가장 가까운 청크 topK 반환. **검색 범위는 항상 userId 소유 문서로 제한**된다 — documentIds 를 주면 그 중 소유한 것만, 비어 있으면 소유 문서 전체." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "검색 결과"), - @ApiResponse(responseCode = "400", description = "queryEmbedding 누락"), + @ApiResponse(responseCode = "400", description = "userId 또는 queryEmbedding 누락"), @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패") }) @PostMapping("/search") public SearchResponse search(@Valid @RequestBody SearchRequest request) { List hits = embeddingService.search( + request.userId(), request.queryEmbedding(), request.queryText(), request.documentIds() == null ? List.of() : request.documentIds(), @@ -47,9 +48,12 @@ public SearchResponse search(@Valid @RequestBody SearchRequest request) { } public record SearchRequest( + // 검색 범위를 확정하는 값 — 없으면 스코프를 정할 수 없으므로 필수. + @NotNull Long userId, @NotNull float[] queryEmbedding, // 선택. 주어지면 벡터 + full-text(BM25) RRF 하이브리드 검색. String queryText, + // 선택. 주면 소유 문서와의 교집합으로 좁힌다. 비면 소유 문서 전체. List documentIds, @Positive Integer topK ) { diff --git a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java index 51d4781a..5c7b5625 100644 --- a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java +++ b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java @@ -6,6 +6,7 @@ import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; import com.stackup.stackup.document.domain.DocumentEmbeddingRepository.EmbeddingChunk; +import com.stackup.stackup.document.application.DocumentEmbeddingService; import com.stackup.stackup.document.domain.DocumentEmbeddingRepository.SearchHit; import com.stackup.stackup.resume.domain.Resume; import com.stackup.stackup.resume.domain.ResumeFileType; @@ -33,13 +34,14 @@ * 반복할 수 있고, 실제로 3개 호출부 중 어디도 삭제를 확인하지 않았다. */ @PostgresRepositoryTest -@Import(JdbcDocumentEmbeddingRepository.class) +@Import({JdbcDocumentEmbeddingRepository.class, DocumentEmbeddingService.class}) class DocumentEmbeddingSearchTest { @Autowired UserRepository userRepository; @Autowired ResumeRepository resumeRepository; @Autowired AnalyzedDocumentRepository documentRepository; @Autowired DocumentEmbeddingRepository embeddingRepository; + @Autowired DocumentEmbeddingService embeddingService; @Autowired EntityManager em; @Test @@ -98,6 +100,45 @@ void hybridSearchExcludesDeletedDocuments() { assertThat(hits).isEmpty(); } + // 검색 범위는 호출자가 뭘 보내든 요청자 소유 문서를 벗어나면 안 된다. + // 이전에는 documentIds 를 그대로 믿었고, 비면 전체 사용자 청크가 대상이었다. + @Test + void serviceScopesSearchToRequestingUsersDocuments() { + AnalyzedDocument mine = document(97005L, "mine"); + AnalyzedDocument stranger = document(97006L, "stranger"); + embeddingRepository.upsertAll(mine.getId(), "test-model", + List.of(new EmbeddingChunk(0, "내 이력서", vector(0.9f)))); + embeddingRepository.upsertAll(stranger.getId(), "test-model", + List.of(new EmbeddingChunk(0, "남의 이력서", vector(0.9f)))); + em.flush(); + + Long myUserId = ownerOf(mine); + + // 남의 문서 id 를 명시해도 결과에 없어야 한다. + assertThat(embeddingService.search(myUserId, vector(0.9f), null, + List.of(mine.getId(), stranger.getId()), 10)) + .extracting(SearchHit::documentId) + .containsExactly(mine.getId()); + + // documentIds 를 비워도 전체 검색이 되지 않는다 — 소유 문서로만 좁혀진다. + assertThat(embeddingService.search(myUserId, vector(0.9f), null, List.of(), 10)) + .extracting(SearchHit::documentId) + .containsExactly(mine.getId()); + } + + // 소유 문서가 하나도 없으면 빈 목록을 그대로 넘기면 안 된다 — 넘기면 다시 전체 검색이다. + @Test + void serviceReturnsEmptyWhenUserOwnsNothing() { + AnalyzedDocument stranger = document(97007L, "stranger-only"); + embeddingRepository.upsertAll(stranger.getId(), "test-model", + List.of(new EmbeddingChunk(0, "남의 문서뿐", vector(0.9f)))); + User loner = userRepository.save(User.createGithubUser(97008L, "loner", null, null, "t")); + em.flush(); + + assertThat(embeddingService.search(loner.getId(), vector(0.9f), null, List.of(), 10)) + .isEmpty(); + } + private List search(List documentIds) { return embeddingRepository.search(vector(0.9f), null, documentIds, 10); } @@ -108,6 +149,10 @@ private static float[] vector(float head) { return v; } + private Long ownerOf(AnalyzedDocument doc) { + return doc.getResume().getUser().getId(); + } + private AnalyzedDocument document(Long githubId, String name) { User user = userRepository.save(User.createGithubUser(githubId, name, null, null, "t")); Resume resume = resumeRepository.save( diff --git a/docs/messaging.md b/docs/messaging.md index f3b59057..4712154a 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -745,11 +745,25 @@ docker exec stackup-rabbitmq rabbitmqadmin \ |--------|------|--------|------| | `GET` | `/api/internal/users/{userId}/github-token` | AI | 사용자별 GitHub access token을 분석 시점에 짧게 위임 (envelope에 비밀 미동봉) | | `PUT` | `/api/internal/documents/{documentId}/embeddings` | AI | 청크 + 임베딩을 `document_embeddings`에 idempotent upsert | -| `POST` | `/api/internal/embeddings/search` | AI | RAG 검색 — pgvector cosine topK (queryText 동봉 시 벡터+BM25 RRF 하이브리드). 실패 시 AI 는 빈 결과로 폴백 (non-fatal) | +| `POST` | `/api/internal/embeddings/search` | AI | RAG 검색 — pgvector cosine topK (queryText 동봉 시 벡터+BM25 RRF 하이브리드). **`userId` 필수** — 검색 범위가 항상 그 사용자 소유·미삭제 문서로 제한된다(§10.1). 실패 시 AI 는 빈 결과로 폴백 (non-fatal) | | `POST` | `/api/internal/ai-logs` | AI | LLM 호출별 토큰·지연시간을 `ai_request_logs` 에 기록 (fire-and-forget, 실패 무시) | 요청·응답 스키마 및 인증 규약은 [`/docs/api-conventions.md §10`](./api-conventions.md) 참조. +#### 10.1 임베딩 검색 스코프 + +`POST /api/internal/embeddings/search` 는 **호출자가 무엇을 보내든 요청자 소유 문서를 벗어나지 않는다.** + +- `userId` 필수. AI 는 `envelope.context.user_id` 를 그대로 싣는다(별도 계약 추가 없이 이미 있는 값). +- `documentIds` 를 주면 **소유 문서와의 교집합**만 대상 — 요청한 id 를 그대로 믿지 않는다. +- `documentIds` 가 비면 그 사용자의 활성 문서 전체. (이전 규약인 "비면 전체 사용자 대상"은 폐기) +- 교집합이 비면 검색하지 않고 빈 결과를 준다 — 빈 목록을 그대로 넘기면 다시 전체 검색이 된다. +- soft delete 된 문서의 청크는 검색 쿼리에서 제외된다(`ACTIVE_DOC_JOIN`). + +이전에는 스코프 방어가 전적으로 호출자에게 있었다. AI 호출부 3곳이 모두 빈 목록을 사전에 +걸러줘서 실제 유출은 없었지만, 호출부가 하나 늘거나 가드를 빠뜨리면 남의 이력서 청크가 +프롬프트로 들어간다. `user_id` 를 못 얻는 경우 AI 는 검색을 건너뛰고 `(none)` 으로 폴백한다. + 큐 상태 확인: ```bash docker exec stackup-rabbitmq rabbitmqctl list_queues -q name messages consumers diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 6dbf1cef..a842443e 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -326,7 +326,7 @@ export interface paths { put?: never; /** * pgvector cosine topK 검색 - * @description queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 가 비어 있으면 전체. + * @description queryEmbedding 으로 가장 가까운 청크 topK 반환. **검색 범위는 항상 userId 소유 문서로 제한**된다 — documentIds 를 주면 그 중 소유한 것만, 비어 있으면 소유 문서 전체. */ post: operations["internalSearchEmbeddings"]; delete?: never; @@ -1226,6 +1226,8 @@ export interface components { idempotencyKey?: string; }; SearchRequest: { + /** Format: int64 */ + userId: number; queryEmbedding: number[]; queryText?: string; documentIds?: number[]; @@ -2645,7 +2647,7 @@ export interface operations { "*/*": components["schemas"]["SearchResponse"]; }; }; - /** @description queryEmbedding 누락 */ + /** @description userId 또는 queryEmbedding 누락 */ 400: { headers: { [name: string]: unknown;