From adfd50d1fdd1a22073ec277364cafa0d28cbe790 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 22 Jul 2026 15:11:39 +0900 Subject: [PATCH 01/18] refs #702: isolate HMM backoffice branch and restore full menus --- .../702-hmm-backoffice-identity/README.md | 26 +++- .../HmmKnowledgeSchemaInitializer.java | 143 ++++++++++++++++++ .../service/VectorKnowledgeService.java | 25 +-- .../mapper/ProtectedObjectMapper.xml | 40 ++--- src/main/resources/mapper/UserMapper.xml | 4 +- .../resources/templates/fragments/layout.html | 6 +- .../web/GuidedFlowTemplateTest.java | 27 +--- 7 files changed, 211 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index a4457ef..b3f9325 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -1,7 +1,7 @@ # HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702) > 상태: Implementing -> 추적: Redmine #702 +> 추적: Redmine #702 / Git 브랜치: `hmm-backoffice` > 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN` ## 목적 @@ -20,6 +20,8 @@ | 역할 | `HMM_ACCESS_ROLES`, `HMM_EMPLOYEE_ACCESS_ROLES`, `HMM_GROUP_ACCESS_ROLES` | 직원 직접 역할과 그룹 상속 역할을 분리 | | 토큰 | `HMM_ACCESS_BEARER_TOKENS` | 원문은 발급 화면에서 한 번만 표시하고 SHA-256 해시만 저장 | | 감사 | `HMM_ACCESS_AUDIT` | 사용자·그룹·역할·토큰 변경 이력 저장 | +| 지식 문서 | `HMM_KNOWLEDGE_DOCUMENTS` | 파일명·원문·BLOB·Abstract·생성일자를 저장 | +| 지식 청크 | `HMM_KNOWLEDGE_CHUNKS`, `HMM_KNOWLEDGE_TAGS` | 문서별 청크와 정규화된 Tag·벡터를 저장 | `HMM_ORG_TEAMS`는 인사 조직 원장이므로 접근 그룹과 혼합하지 않는다. 이로써 한 직원이 여러 업무 접근 그룹에 속하면서도 인사 소속팀은 하나로 유지된다. @@ -35,6 +37,8 @@ HMM_HR_EMPLOYEES ──< HMM_EMPLOYEE_ACCESS_ROLES >── HMM_ACCESS_ROLES HMM_HR_EMPLOYEES ──< HMM_ACCESS_BEARER_TOKENS HMM_ACCESS_AUDIT records every backoffice change + +HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS ``` ## 화면 및 호환성 @@ -43,15 +47,29 @@ HMM_ACCESS_AUDIT records every backoffice change `employment_status`의 `ACTIVE`/`INACTIVE`에 매핑한다. - `/groups`는 `HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록 논리 접근 그룹임을 화면에 명시한다. -- `/roles`는 HMM 접근 역할만 관리한다. 기존 VPD 객체 권한 규칙은 이번 범위에서 노출하지 않는다. +- `/roles`는 HMM 접근 역할만 관리한다. - `/tokens`는 HMM 직원에게 토큰을 발급·회수한다. KB 이해당사자 원장은 참조하지 않는다. +- `/objects`, `/permissions`, `/masking-rules`, `/probe`, MCP 화면은 삭제하지 않는다. 기존 CB 화면 + 계약은 `CB_PROTECTED_*`, `CB_PERMISSION*`, `CB_VECTOR_*` 호환 뷰로 유지하고, 실제 데이터는 + `HMM_ACCESS_*` 및 `HMM_KNOWLEDGE_*`에 저장한다. +- 애플리케이션 시작 시 `HmmKnowledgeSchemaInitializer`가 HMM 지식 테이블·시퀀스·인덱스를 + 멱등적으로 준비하고 `CB_VECTOR_*` 조회 호환 뷰를 갱신한다. 기존 물리 `CB_VECTOR_*` 테이블이 + 존재하는 환경은 덮어쓰지 않는다. + +## 브랜치·배포 기준 + +- Smilegate 기준은 `main`이며, HMM 백오피스의 구현·배포·검증은 `hmm-backoffice` 브랜치만 사용한다. +- HMM 배포 전에 현재 브랜치명을 확인하고, `main` 또는 `hmm-ai-agent` 브랜치에서는 HMM 백오피스 + JAR를 배포하지 않는다. +- 메뉴 전수 점검은 로그인 세션으로 원래 메뉴 URL 전체를 순회한다. HTTP 200만으로 통과시키지 않고 + 화면 내 `ORA-`, `데이터 처리 오류`, `Whitelabel Error Page`도 함께 검사한다. ## 보안·검증 - 토큰 원문, DB 비밀번호, Wallet은 테이블·Git·로그에 저장하지 않는다. - DDL은 재실행 가능해야 하며 기존 HR 행을 수정하거나 삭제하지 않는다. -- 배포 전 SQLcl로 테이블·FK·시드 역할을 확인하고, 배포 후 사용자·그룹·역할·토큰 화면을 HTTP - 수준에서 확인한다. +- 배포 전 전체 자동 테스트를 통과시키고, 배포 후 사용자·접근 그룹·역할·토큰뿐 아니라 보호 객체, + 권한, 마스킹, 접근 검증, 지식자료, MCP, 운영 메뉴를 로그인 세션으로 전수 확인한다. ## 롤백 diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java new file mode 100644 index 0000000..b28a925 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java @@ -0,0 +1,143 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Keeps the HMM knowledge store as the source of truth while preserving the + * CB_VECTOR_* contracts that the existing ORDS and backoffice screens use. + */ +@Component +@Order(0) +public class HmmKnowledgeSchemaInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(HmmKnowledgeSchemaInitializer.class); + + private static final List TABLES = List.of( + new SchemaObject("HMM_KNOWLEDGE_DOCUMENTS", """ + CREATE TABLE hmm_knowledge_documents ( + document_id VARCHAR2(200) PRIMARY KEY, + file_name VARCHAR2(500) NOT NULL, + source_uri VARCHAR2(1000), + abstract_text CLOB, + original_text CLOB, + file_content BLOB, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL + ) + """), + new SchemaObject("HMM_KNOWLEDGE_CHUNKS", """ + CREATE TABLE hmm_knowledge_chunks ( + chunk_id NUMBER PRIMARY KEY, + document_id VARCHAR2(200) NOT NULL + REFERENCES hmm_knowledge_documents(document_id) ON DELETE CASCADE, + chunk_no NUMBER NOT NULL, + title VARCHAR2(500), + chunk_text CLOB NOT NULL, + source_uri VARCHAR2(1000), + embedding VECTOR, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT hmm_knowledge_chunk_uk UNIQUE (document_id, chunk_no) + ) + """), + new SchemaObject("HMM_KNOWLEDGE_TAGS", """ + CREATE TABLE hmm_knowledge_tags ( + chunk_id NUMBER NOT NULL + REFERENCES hmm_knowledge_chunks(chunk_id) ON DELETE CASCADE, + tech_tag VARCHAR2(100) NOT NULL, + CONSTRAINT hmm_knowledge_tag_pk PRIMARY KEY (chunk_id, tech_tag), + CONSTRAINT hmm_knowledge_tag_format_ck + CHECK (REGEXP_LIKE(tech_tag, '^[A-Z0-9_-]+$')) + ) + """) + ); + + private final JdbcTemplate jdbcTemplate; + + public HmmKnowledgeSchemaInitializer(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void run(org.springframework.boot.ApplicationArguments args) { + TABLES.forEach(this::ensureTable); + ensureSequence(); + ensureIndex(); + ensureLegacyViews(); + log.info("HMM knowledge schema and CB_VECTOR compatibility views are ready"); + } + + private void ensureTable(SchemaObject table) { + if (!objectExists(table.name(), "TABLE")) { + jdbcTemplate.execute(table.ddl()); + } + } + + private void ensureSequence() { + if (!objectExists("HMM_KNOWLEDGE_CHUNK_SEQ", "SEQUENCE")) { + jdbcTemplate.execute("CREATE SEQUENCE hmm_knowledge_chunk_seq START WITH 30000 INCREMENT BY 1 NOCACHE"); + } + } + + private void ensureIndex() { + if (!objectExists("HMM_KNOWLEDGE_TAG_IX", "INDEX")) { + jdbcTemplate.execute("CREATE INDEX hmm_knowledge_tag_ix ON hmm_knowledge_tags (tech_tag, chunk_id)"); + } + } + + private void ensureLegacyViews() { + replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_CHUNK", """ + CREATE OR REPLACE VIEW cb_vector_document_chunk AS + SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding, created_at + FROM hmm_knowledge_chunks + """); + replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_TAG", """ + CREATE OR REPLACE VIEW cb_vector_document_tag AS + SELECT chunk_id, tech_tag + FROM hmm_knowledge_tags + """); + replaceViewUnlessPhysicalTable("CB_VECTOR_SEARCH_DOCUMENTS", """ + CREATE OR REPLACE VIEW cb_vector_search_documents AS + SELECT c.chunk_id, + c.document_id, + c.chunk_no, + c.title, + c.chunk_text, + c.source_uri, + c.embedding, + (SELECT LISTAGG(t.tech_tag, ',') WITHIN GROUP (ORDER BY t.tech_tag) + FROM hmm_knowledge_tags t + WHERE t.chunk_id = c.chunk_id) AS tech_tag + FROM hmm_knowledge_chunks c + WHERE EXISTS ( + SELECT 1 FROM hmm_knowledge_tags t WHERE t.chunk_id = c.chunk_id + ) + """); + } + + private void replaceViewUnlessPhysicalTable(String name, String ddl) { + if (objectExists(name, "TABLE")) { + log.info("{} is a physical legacy table; leaving it unchanged", name); + return; + } + jdbcTemplate.execute(ddl); + } + + private boolean objectExists(String name, String type) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM user_objects + WHERE object_name = ? + AND object_type = ? + """, Integer.class, name, type); + return count != null && count > 0; + } + + private record SchemaObject(String name, String ddl) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java index 240bb03..98a716c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java @@ -56,9 +56,9 @@ public class VectorKnowledgeService { } public VectorKnowledgeSummary summary() { - int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk"); - int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk"); - int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag"); + int documents = count("SELECT COUNT(*) FROM hmm_knowledge_documents"); + int chunks = count("SELECT COUNT(*) FROM hmm_knowledge_chunks"); + int tags = count("SELECT COUNT(*) FROM hmm_knowledge_tags"); boolean registered = protectedObjectService.findEnabled().stream() .anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName())); return new VectorKnowledgeSummary( @@ -100,6 +100,11 @@ public class VectorKnowledgeService { List chunks = VectorChunker.chunk(content, command.chunkSize()); replaceDocument(documentId); + jdbcTemplate.update(""" + INSERT INTO hmm_knowledge_documents + (document_id, file_name, source_uri, abstract_text, original_text, updated_at) + VALUES (?, ?, ?, ?, ?, SYSTIMESTAMP) + """, documentId, title, sourceUri, title, content); long nextChunkId = nextChunkId(chunks.size()); int tagCount = 0; for (VectorChunk chunk : chunks) { @@ -107,13 +112,13 @@ public class VectorKnowledgeService { String embeddingJson = json(embedding); long chunkId = nextChunkId++; jdbcTemplate.update(""" - INSERT INTO cb_vector_document_chunk + INSERT INTO hmm_knowledge_chunks (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding) VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?)) """, chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson); for (String tag : tags) { jdbcTemplate.update(""" - INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) + INSERT INTO hmm_knowledge_tags (chunk_id, tech_tag) VALUES (?, ?) """, chunkId, tag); tagCount++; @@ -143,18 +148,18 @@ public class VectorKnowledgeService { private void replaceDocument(String documentId) { jdbcTemplate.update(""" - DELETE FROM cb_vector_document_tag + DELETE FROM hmm_knowledge_tags WHERE chunk_id IN ( - SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ? + SELECT chunk_id FROM hmm_knowledge_chunks WHERE document_id = ? ) """, documentId); - jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId); + jdbcTemplate.update("DELETE FROM hmm_knowledge_documents WHERE document_id = ?", documentId); } private long nextChunkId(int chunkCount) { try { Long sequenceValue = jdbcTemplate.queryForObject( - "SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class); + "SELECT hmm_knowledge_chunk_seq.NEXTVAL FROM dual", Long.class); if (sequenceValue != null) { return sequenceValue; } @@ -163,7 +168,7 @@ public class VectorKnowledgeService { // fallback keeps the demonstration usable; the setup SQL creates it. } Long max = jdbcTemplate.queryForObject( - "SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class); + "SELECT NVL(MAX(chunk_id), 28000) FROM hmm_knowledge_chunks", Long.class); return (max == null ? 28000 : max) + 1; } diff --git a/src/main/resources/mapper/ProtectedObjectMapper.xml b/src/main/resources/mapper/ProtectedObjectMapper.xml index 0a7bfae..2e10bed 100644 --- a/src/main/resources/mapper/ProtectedObjectMapper.xml +++ b/src/main/resources/mapper/ProtectedObjectMapper.xml @@ -4,28 +4,28 @@ @@ -38,8 +38,8 @@ AND object_name NOT LIKE 'BIN$%' AND NOT EXISTS ( SELECT 1 - FROM sg_protected_object po - JOIN sg_permission p ON p.target_name = po.object_name + FROM cb_protected_object po + JOIN cb_permission p ON p.target_name = po.object_name WHERE po.owner = all_objects.owner AND po.object_name = all_objects.object_name AND po.enabled_yn = 'Y' @@ -64,7 +64,7 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE object_id = #{objectId} ORDER BY column_id @@ -77,7 +77,7 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE object_id IN #{objectId,jdbcType=NUMERIC} @@ -93,7 +93,7 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE column_id = #{columnId} @@ -105,21 +105,21 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE object_id = #{objectId} AND column_name = UPPER(#{columnName,jdbcType=VARCHAR}) - INSERT INTO sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description) + INSERT INTO cb_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description) VALUES ( #{objectId}, UPPER(#{command.owner}), @@ -131,7 +131,7 @@ - INSERT INTO sg_protected_column ( + INSERT INTO cb_protected_column ( column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method ) VALUES ( @@ -140,7 +140,7 @@ - UPDATE sg_protected_column + UPDATE cb_protected_column SET sensitivity_level = #{sensitivityLevel,jdbcType=VARCHAR}, redaction_method = #{redactionMethod,jdbcType=VARCHAR}, sensitive_yn = CASE @@ -152,25 +152,25 @@ - UPDATE sg_protected_object + UPDATE cb_protected_object SET ords_path = #{ordsPath,jdbcType=VARCHAR} WHERE object_id = #{objectId,jdbcType=NUMERIC} - UPDATE sg_protected_object + UPDATE cb_protected_object SET description = #{description,jdbcType=VARCHAR} WHERE object_id = #{objectId,jdbcType=NUMERIC} - UPDATE sg_protected_object + UPDATE cb_protected_object SET enabled_yn = 'Y' WHERE object_id = #{objectId} - UPDATE sg_protected_object + UPDATE cb_protected_object SET enabled_yn = 'N' WHERE object_id = #{objectId} diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index 9eeadb5..0354b3e 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -27,8 +27,8 @@ + +

Agent Factory 등록 주소: https://hmm-mcp.cloud-handson.com/mcp

+ + +
+
+
+ 선택형 레거시 연동

ORDS Base URL

-

저장 후 접근 검증과 조회 연동이 이 주소를 사용합니다.

+

기존 VPD/ORDS 접근 검증과 ORDS Handler 운영에만 사용합니다. 비워 두면 해당 기능은 미설정 상태로 표시되며 HMM HR 질의에는 영향이 없습니다.

- +
diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java new file mode 100644 index 0000000..7b2b251 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java @@ -0,0 +1,37 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting; +import com.cloudhandson.vpdbackoffice.mapper.SettingMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.DefaultApplicationArguments; + +class HmmRuntimeSettingsInitializerTest { + + @Test + void removesOnlyTheRetiredKbOrdsSetting() { + SettingMapper mapper = Mockito.mock(SettingMapper.class); + when(mapper.findByKey(SettingService.ORDS_BASE_URL)).thenReturn(new BackofficeSetting( + SettingService.ORDS_BASE_URL, + "https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords/")); + + new HmmRuntimeSettingsInitializer(mapper).run(new DefaultApplicationArguments()); + + verify(mapper).deleteByKey(SettingService.ORDS_BASE_URL); + } + + @Test + void keepsAnOperatorConfiguredLegacyOrdsSetting() { + SettingMapper mapper = Mockito.mock(SettingMapper.class); + when(mapper.findByKey(SettingService.ORDS_BASE_URL)).thenReturn(new BackofficeSetting( + SettingService.ORDS_BASE_URL, "https://ords.hmm.example/ords")); + + new HmmRuntimeSettingsInitializer(mapper).run(new DefaultApplicationArguments()); + + verify(mapper, never()).deleteByKey(SettingService.ORDS_BASE_URL); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index b9cf90c..bb7bc1d 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -1,73 +1,67 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; -import com.fasterxml.jackson.databind.ObjectMapper; + import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Test; class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); - private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService(); - private final McpSseService service = new McpSseService( - selectAiAgentOrdsService, - objectMapper - ); + private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner(); + private final McpSseService service = new McpSseService(agentToolRunner, objectMapper); @Test - void listsOnlyVpdSelectAiToolWithPromptInput() { + void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() { ObjectNode response = service.handle("default", request(1, "tools/list")); var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(1); - var selectAi = tools.get(0); - assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd"); - assertThat(selectAi.path("inputSchema").path("required")) - .extracting(node -> node.asText()) - .contains("prompt"); - assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); - assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue(); - assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); + assertThat(tools).hasSize(3); + assertThat(tools).extracting(node -> node.path("name").asText()) + .containsExactly("resolve_hr_term", "search_hr_data", "search_hr_policy"); + assertThat(tools.get(0).path("inputSchema").path("required")) + .extracting(JsonNode::asText) + .containsExactly("term"); + assertThat(tools.get(1).path("inputSchema").path("required")) + .extracting(JsonNode::asText) + .containsExactly("query"); + assertThat(tools.get(2).path("inputSchema").path("required")) + .extracting(JsonNode::asText) + .containsExactly("query"); } @Test - void callsVpdSelectAiThroughOrdsService() { + void callsHMMTermResolverWithTheApprovedAgentToolAndInputName() { ObjectNode request = request(2, "tools/call"); - ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "ords.query.kb_select_ai_vpd"); - ObjectNode arguments = params.putObject("arguments"); - arguments.put("prompt", "고객 수를 조회해 줘"); - arguments.put("limit", 25); + ObjectNode params = request.putObject("params"); + params.put("name", "resolve_hr_term"); + params.putObject("arguments").put("term", "연차 이월"); - ObjectNode response = service.handle("default", request, "user-bearer"); + ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session"); - CapturingSelectAiAgentOrdsService agentService = - (CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService; - assertThat(agentService.bearerToken).isEqualTo("user-bearer"); - assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘"); - assertThat(agentService.limit).isEqualTo(25); + assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER"); + assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") - .contains("SELECT COUNT(*) FROM KB_CUSTOMERS"); + .contains("resolve_hr_term") + .contains("HMM_HR_TERM_RESOLVER") + .contains("ANNUAL_LEAVE_CARRYOVER"); } @Test - void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { + void rejectsUnknownToolsWithoutCallingTheAgentRunner() { ObjectNode request = request(3, "tools/call"); - ObjectNode params = (ObjectNode) request.putObject("params"); + ObjectNode params = request.putObject("params"); params.put("name", "ords.query.kb_select_ai_vpd"); - params.putObject("arguments").put("prompt", "고객 수를 조회해 줘"); + params.putObject("arguments").put("prompt", "legacy query"); - ObjectNode response = service.handle("default", request, ""); + ObjectNode response = service.handle("default", request); - assertThat(response.path("error").isMissingNode()).isTrue(); - assertThat(response.path("result").path("isError").asBoolean()).isTrue(); - assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("VPD_TOKEN_DENIED") - .contains("권한이 없습니다"); + assertThat(response.path("result").isMissingNode()).isTrue(); + assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool"); } private ObjectNode request(int id, String method) { @@ -78,24 +72,18 @@ class McpSseServiceTest { return request; } - private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService { + private final class CapturingHmmAiAgentToolRunner implements HmmAiAgentToolRunner { - private String bearerToken; - private String prompt; - private int limit; - - private CapturingSelectAiAgentOrdsService() { - super(null, null, new ObjectMapper()); - } + private String toolName; + private ObjectNode input; @Override - public JsonNode run(String bearerToken, String prompt, int limit) { - this.bearerToken = bearerToken; - this.prompt = prompt; - this.limit = limit; - return new ObjectMapper().createObjectNode() - .put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") - .put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS"); + public JsonNode run(String requestedToolName, ObjectNode requestedInput) { + toolName = requestedToolName; + input = requestedInput.deepCopy(); + return objectMapper.createObjectNode() + .put("termCode", "ANNUAL_LEAVE_CARRYOVER") + .put("termName", "연차 이월"); } } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index ddecfe5..43b9913 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -262,7 +262,12 @@ class GuidedFlowTemplateTest { assertThat(chatbot).contains("검증 세션 사용자").contains("태그 벡터 검색"); assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool"); assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path"); - assertThat(sse).contains("Instruction / parameter mapping").contains("Authorization"); + assertThat(sse) + .contains("Instruction / parameter mapping") + .contains("resolve_hr_term") + .contains("search_hr_data") + .contains("search_hr_policy") + .doesNotContain("kb_select_ai_vpd", "KB_CLAIMS"); } @Test diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java index 068e8bb..6eac9da 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java @@ -24,13 +24,17 @@ class SettingsTemplateRenderTest { var context = new Context(Locale.KOREAN); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("ordsBaseUrl", "https://ords.example.test/ords"); + context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp"); String connection = engine.process("settings", context); String database = engine.process("settings-database", context); assertThat(connection) - .contains("ORDS Base URL") + .contains("HMM HR Agent 도구") + .contains("https://hmm-mcp.cloud-handson.com/mcp") + .contains("Legacy ORDS Base URL") .contains("/settings/database") + .doesNotContain("g329127dfd380ad-kbaipoc") .doesNotContain("/settings/database/initialize"); assertThat(database) .contains("DB 준비 상태") From ee355a9282d798c2e86af16076bb07e79872fc91 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 22 Jul 2026 16:33:11 +0900 Subject: [PATCH 03/18] refs #699: configure HMM demo users and OCI runtime --- docs/design/699-hmm-mcp-demo-users/README.md | 43 +++++ poc4_active_source_20260714/.env.sample | 4 +- .../HMM_BRAND_REFRESH.md | 12 ++ poc4_active_source_20260714/SOURCE_README.md | 10 +- .../apps/poc4/mcp_discovery_ui.py | 159 +++++++++++++++--- .../config/vpd_token_presets.json | 50 +++++- poc4_active_source_20260714/requirements.txt | 2 +- .../src/agent_console/presentation.py | 13 ++ .../tests/test_scenarios.py | 12 ++ 9 files changed, 269 insertions(+), 36 deletions(-) create mode 100644 docs/design/699-hmm-mcp-demo-users/README.md diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md new file mode 100644 index 0000000..ea4b393 --- /dev/null +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -0,0 +1,43 @@ +# HMM MCP 데모 사용자 preset (#699) + +> 상태: Implementing +> 추적: Redmine #699 / Git 브랜치: `hmm-backoffice` +> 대상: `hmm.cloud-handson.com` / `/opt/hmm-poc4` + +## 목적 + +HMM AI 업무 에이전트에서 HMM 가상 HR 조직의 팀장·팀원을 선택해 같은 질문을 사용자 관점으로 +시험한다. MCP 접속 토큰과 데모 사용자 정보를 분리하여 토큰 원문을 화면·JSON·Git에 남기지 않는다. + +## 구성 + +```text +vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_env) + → Streamlit 데모 사용자 선택 + → HMM MCP gateway bearer token (HMM_MCP_BEARER_TOKEN, 서버 .env 전용) + → hmm-mcp.cloud-handson.com/mcp + → HMM HR Select AI Agent Tool +``` + +- JSON 파일명은 이전 배포 호환성을 위해 유지한다. 내용은 KB VPD 사용자가 아닌 HMM HR 데모 사용자다. +- 각 preset은 `mcp_token_env`로 서버 환경변수만 참조한다. token 원문은 절대 JSON에 쓰지 않는다. +- 선택한 사용자는 자연어의 ‘나’, ‘내’, ‘우리 팀’을 해석하는 데모 문맥이다. 현재 HMM MCP의 gateway + token은 공용이므로 이 선택 자체가 DB 행 수준 권한을 강제한다고 표시하지 않는다. +- HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다. + +## 런타임 구성 + +- Streamlit은 `requirements.txt`의 OCI SDK를 `/opt/hmm-poc4/.venv`에 설치해 사용한다. +- OCI Generative AI 인증은 `/home/opc/.oci/config`의 `DEFAULT` profile과 해당 private key를 사용한다. +- `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`는 + 배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다. +- MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다. + +## 완료 기준 + +1. 팀장 E1001과 팀원 preset이 사이드바에서 선택된다. +2. MCP bearer token은 화면·대화 JSON·Git에 노출되지 않는다. +3. E1001의 ‘우리 팀 대기 휴가’와 E1005/E1007의 ‘내 휴가’가 HMM MCP로 정상 호출된다. +4. 기존 KB MCP·KB VPD 안내가 HMM HR 질문 생성 경로에 사용되지 않는다. +5. OCI SDK가 `DEFAULT` profile로 GPT-5.4-mini를 실제 호출한다. +6. MCP 호출 상세의 JSON·code가 밝은 테마에서 읽힌다. diff --git a/poc4_active_source_20260714/.env.sample b/poc4_active_source_20260714/.env.sample index a89bc8e..c25caae 100644 --- a/poc4_active_source_20260714/.env.sample +++ b/poc4_active_source_20260714/.env.sample @@ -31,6 +31,8 @@ POC3_MCP_PROVIDER=custom_python POC3_MCP_BASE_URL=http://127.0.0.1:8500 POC3_MCP_AUTH_MODE=bearer POC3_MCP_TOKEN= +# HMM MCP gateway token. 실제 값은 배포 서버 .env에만 둡니다. +HMM_MCP_BEARER_TOKEN= POC3_MCP_TIMEOUT_SECONDS=30 POC3_MCP_FALLBACK_TO_MOCK=false POC3_MCP_LIVE_SMOKE=false @@ -39,7 +41,7 @@ POC3_MCP_LIVE_SMOKE=false # 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다. # 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다. POC4_MCP_SERVERS_FILE=config/mcp_servers.json -POC4_MCP_DEFAULT_SERVER_ID=local_adb_mcp +POC4_MCP_DEFAULT_SERVER_ID=hmm_hr_mcp # MCP discovery UI conversation history store # 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3 diff --git a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md b/poc4_active_source_20260714/HMM_BRAND_REFRESH.md index 5e5aefd..d98f22a 100644 --- a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md +++ b/poc4_active_source_20260714/HMM_BRAND_REFRESH.md @@ -13,3 +13,15 @@ The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`). Its gateway credential is supplied only at runtime as `HMM_MCP_BEARER_TOKEN` in the deployed `.env`; it is intentionally not committed and is not the selected VPD user's token. + +## HMM demo user presets + +`config/vpd_token_presets.json` is retained as a legacy deployment filename, but its content is +an HMM HR demo-user list. The Streamlit sidebar uses it to select a manager or team-member +persona and resolves pronouns such as “내” and “우리 팀” in the test question. Each preset uses +`mcp_token_env: HMM_MCP_BEARER_TOKEN`; the bearer value stays in `/opt/hmm-poc4/.env` and is +never rendered, downloaded, or committed. The selector is a test context, not a claim of +row-level authorization enforcement. + +The security-operation link in the Streamlit console points to +`https://hmm-backoffice.cloud-handson.com/`, the HMM access-management backoffice. diff --git a/poc4_active_source_20260714/SOURCE_README.md b/poc4_active_source_20260714/SOURCE_README.md index 7cababa..e86baa2 100644 --- a/poc4_active_source_20260714/SOURCE_README.md +++ b/poc4_active_source_20260714/SOURCE_README.md @@ -15,7 +15,9 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po - Python 3.11 이상을 사용합니다. - 이 개발 서버의 기본 `python3`가 3.6 계열이면 문법 검증이 실패합니다. -- 배포 서버 검증 런타임: `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` +- 배포 서버는 `/opt/hmm-poc4/.venv`와 Python 3.11을 사용합니다. +- OCI Generative AI SDK 인증은 배포 서버의 `/home/opc/.oci/config` `DEFAULT` profile을 사용합니다. + private key와 compartment OCID는 Git에 저장하지 않고 서버의 `~/.oci`와 `.env`에서만 관리합니다. ## Deployment DB reference @@ -46,12 +48,14 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po ## Security note - 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요. -- 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요. +- `config/vpd_token_presets.json`은 파일명 호환성을 유지한 HMM **데모 사용자** 목록입니다. + 사용자별 `mcp_token_env`는 배포 환경의 `HMM_MCP_BEARER_TOKEN`만 참조하며, token 원문은 JSON·Git에 저장하지 않습니다. - 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다. +- MCP 호출 상세의 JSON/code 영역은 공통 presentation CSS에서 밝은 배경과 어두운 글자를 함께 지정합니다. ## 구성 원칙 -- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, 사용자 preset, 데모 질문 JSON +- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, HMM 데모 사용자 preset, 데모 질문 JSON - `src/poc4/`: Streamlit과 분리 가능한 화면 보조 모듈 및 도메인 로직 - `apps/poc4/`: 레거시 호환 entrypoint. 화면 조립과 사용자 상호작용만 담당하도록 점진적으로 축소 - `tests/`: 설정 파일과 순수 Python 모듈의 회귀 검증 diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index 1f5b94c..b7adfbd 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -62,7 +62,7 @@ from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios LOG = logging.getLogger(__name__) MCP_PROTOCOL_VERSION = "2025-11-25" -PREFERRED_TOOL = "ords.query.kb_select_ai_vpd" +PREFERRED_TOOL = "search_hr_data" DEFAULT_QUESTION = "" MAX_RESPONSE_BYTES = 1_000_000 MAX_CONVERSATION_MESSAGES = 8 @@ -80,8 +80,8 @@ VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json" DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json" APP_PROFILE_FILE = ROOT / "config" / "app_profile.json" CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" -DEFAULT_VPD_USER_ID = "FC00789" -VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" +DEFAULT_VPD_USER_ID = "E1001" +VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" @@ -1471,6 +1471,7 @@ class VpdTokenPreset: scope: str token: str = field(repr=False, compare=False) is_default: bool = False + team: str = "" @property def display_label(self) -> str: @@ -1480,7 +1481,7 @@ class VpdTokenPreset: self.user_id, self.name, self.role, - self.channel, + self.team or self.channel, self.scope, ) if item @@ -1493,6 +1494,7 @@ class VpdTokenPreset: for item in ( self.user_id, self.name, + self.role, ) if item ) @@ -2047,7 +2049,7 @@ def _render_app_header(profile: AppProfile) -> None: render_console_header(st, profile) -def _render_vpd_user_card(preset: VpdTokenPreset) -> None: +def _render_demo_user_card(preset: VpdTokenPreset) -> None: st.markdown( f"""
@@ -2055,10 +2057,10 @@ def _render_vpd_user_card(preset: VpdTokenPreset) -> None: {html.escape(preset.user_id)} · {html.escape(preset.name)}
- {html.escape(preset.role)} · {html.escape(preset.channel)} + {html.escape(preset.role)} · {html.escape(preset.team or preset.channel)}
- 권한 범위: {html.escape(preset.scope)} + 테스트 문맥: {html.escape(preset.scope)}
""", @@ -3625,19 +3627,24 @@ def load_vpd_token_presets( except FileNotFoundError: return () except (OSError, UnicodeError, ValueError): - raise PublicMcpError(f"VPD 토큰 preset 설정을 읽지 못했습니다: {path}") from None + raise PublicMcpError(f"데모 사용자 preset 설정을 읽지 못했습니다: {path}") from None raw_presets = payload.get("presets") if isinstance(payload, Mapping) else None if not isinstance(raw_presets, list): - raise PublicMcpError("VPD 토큰 preset 설정에 presets 배열이 필요합니다.") + raise PublicMcpError("데모 사용자 preset 설정에 presets 배열이 필요합니다.") presets: list[VpdTokenPreset] = [] seen: set[str] = set() for item in raw_presets: if not isinstance(item, Mapping) or item.get("enabled", True) is not True: continue - token = _normalized_bearer(item.get("token")) + token_env = str( + item.get("mcp_token_env") or item.get("token_env") or "" + ).strip() + token = _normalized_bearer( + _runtime_env_value(token_env) if token_env else item.get("token") + ) user_id = str(item.get("user_id") or "").strip() - if not token or not user_id or user_id in seen: + if not user_id or user_id in seen: continue presets.append( VpdTokenPreset( @@ -3645,9 +3652,10 @@ def load_vpd_token_presets( user_id=user_id, name=str(item.get("name") or "").strip(), role=str(item.get("role") or "").strip(), - channel=str(item.get("channel") or "").strip(), + channel=str(item.get("channel") or item.get("team") or "").strip(), scope=str(item.get("scope") or "").strip(), is_default=item.get("default") is True, + team=str(item.get("team") or "").strip(), ) ) seen.add(user_id) @@ -5264,6 +5272,16 @@ def prepare_tool_query_for_mcp( fallback = str(question or "").strip() if not fallback: return fallback + if server.server_id == "hmm_hr_mcp": + return _prepare_hmm_hr_tool_query( + question=fallback, + tool=tool, + model_profile_key=model_profile_key, + selected_user_id=selected_user_id, + selected_user_role=selected_user_role, + selected_user_team=selected_user_channel, + selected_user_scope=selected_user_scope, + ) normalized_question = " ".join(fallback.casefold().split()) individual_scope_terms = ( "내 담당이 아닌", @@ -5414,6 +5432,98 @@ def prepare_tool_query_for_mcp( return _append_query_guidance(rewritten or fallback, query_guidance) +def _hmm_demo_user_context( + question: str, + *, + user_id: str, + role: str, + team: str, + scope: str, +) -> str: + """Make a selected HR persona useful without claiming row-level enforcement.""" + + normalized = str(question or "").strip() + if not user_id: + return normalized + profile = " · ".join(item for item in (user_id, role, team) if item) + purpose = f" 테스트 목적: {scope}." if scope else "" + return ( + f"현재 HMM HR 데모 사용자: {profile}.{purpose} " + "질문의 ‘나’, ‘내’, ‘우리 팀’은 이 데모 사용자를 기준으로 해석하고, " + "실제 행 수준 권한이 적용됐다고 주장하지 마세요.\n" + f"질문: {normalized}" + ) + + +def _prepare_hmm_hr_tool_query( + *, + question: str, + tool: McpTool, + model_profile_key: str, + selected_user_id: str, + selected_user_role: str, + selected_user_team: str, + selected_user_scope: str, +) -> str: + """Prepare an HMM HR query without inheriting retired KB/VPD prompt rules.""" + + fallback = str(question or "").strip() + if tool.name == "resolve_hr_term": + return fallback + contextual_question = _hmm_demo_user_context( + fallback, + user_id=selected_user_id.strip(), + role=selected_user_role.strip(), + team=selected_user_team.strip(), + scope=selected_user_scope.strip(), + ) + try: + profile = resolve_model_profile(model_profile_key) + client = build_oci_genai_completion_client( + profile.model_id, + profile.answer_model_region, + profile.answer_model_endpoint, + ) + text = client.complete( + system_prompt=( + "You prepare one Korean natural-language query for an HMM HR MCP tool. " + "Return only JSON matching the schema. Do not answer the user, write SQL, " + "or expose/request tokens. Preserve employee codes exactly. " + "The HMM HR data tool can query organization, employees, leave balances, " + "leave requests, attendance, and standardized HR terms. The policy tool searches " + "HR policy PDF abstracts and chunks. A selected demo user only resolves pronouns " + "such as 'my' or 'our team'; do not claim that it enforces database access control." + ), + user_prompt=json.dumps( + { + "question": contextual_question, + "selected_tool_name": tool.name, + "selected_tool_description": tool.description[:1200], + }, + ensure_ascii=False, + ), + response_schema={ + "type": "object", + "additionalProperties": False, + "required": ["tool_query"], + "properties": {"tool_query": {"type": "string"}}, + }, + max_tokens=500, + temperature=temperature_for_model_profile(profile), + ) + parsed = json.loads(text) + rewritten = _clean_agent_tool_query(parsed.get("tool_query"), fallback) + except Exception: + return contextual_question + return _hmm_demo_user_context( + rewritten or fallback, + user_id=selected_user_id.strip(), + role=selected_user_role.strip(), + team=selected_user_team.strip(), + scope=selected_user_scope.strip(), + ) + + def synthesize_answer( *, question: str, @@ -7037,7 +7147,7 @@ def main() -> None: current_vpd_user_id ].select_label st.markdown( - '
VPD 사용자
', + '
데모 사용자
', unsafe_allow_html=True, ) vpd_popover_key = "poc4_vpd_user_popover_open" @@ -7052,7 +7162,7 @@ def main() -> None: on_change="rerun", ): selected_vpd_user_id = st.radio( - "VPD 사용자 선택", + "데모 사용자 선택", options=token_preset_ids, key=vpd_user_key, format_func=lambda user_id: token_preset_by_id[ @@ -7068,10 +7178,13 @@ def main() -> None: ) else: selected_token_preset = None - if configured_mcp_bearer: - # The HMM MCP gateway uses its own server-side credential. VPD user - # tokens remain UI context only and must never be forwarded to it. - st.caption("MCP 인증: 서버 관리 토큰 적용") + selected_preset_bearer = ( + selected_token_preset.token if selected_token_preset is not None else "" + ) + if configured_mcp_bearer or selected_preset_bearer: + # The HMM MCP gateway credential is never rendered. Each demo-user + # preset refers to its runtime env key, allowing secure profile swaps. + st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용") manual_bearer_token = "" elif selected_token_preset is not None: manual_bearer_token = st.text_input( @@ -7080,7 +7193,7 @@ def main() -> None: type="default", disabled=True, key=f"poc4_bearer_token_{selected_token_preset.user_id}", - help="선택한 VPD 사용자의 Bearer Token입니다.", + help="선택한 데모 사용자의 MCP gateway token입니다.", ) else: manual_bearer_token = st.text_input( @@ -7088,13 +7201,9 @@ def main() -> None: type="default", key="poc4_manual_bearer_token", ) - bearer_token = configured_mcp_bearer or ( - selected_token_preset.token - if selected_token_preset is not None - else manual_bearer_token - ) + bearer_token = selected_preset_bearer or configured_mcp_bearer or manual_bearer_token if selected_token_preset is not None: - _render_vpd_user_card(selected_token_preset) + _render_demo_user_card(selected_token_preset) all_conversation_rows = list_conversations(limit=200) current_row = next( diff --git a/poc4_active_source_20260714/config/vpd_token_presets.json b/poc4_active_source_20260714/config/vpd_token_presets.json index 494a426..ecd510d 100644 --- a/poc4_active_source_20260714/config/vpd_token_presets.json +++ b/poc4_active_source_20260714/config/vpd_token_presets.json @@ -1,14 +1,52 @@ { + "version": 2, + "description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.", "presets": [ { "enabled": true, "default": true, - "token": "vpd_live_REPLACE_WITH_USER_TOKEN", - "user_id": "FC00789", - "name": "김설계", - "role": "설계사", - "channel": "설계사", - "scope": "본인 담당 계약 고객" + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1001", + "name": "Kim Minseo", + "role": "HR Team Manager", + "team": "HMM HR Demo Team", + "scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1002", + "name": "Lee Jiwon", + "role": "HR Operations Specialist", + "team": "HMM HR Demo Team", + "scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1003", + "name": "Park Dohyun", + "role": "People Analytics Analyst", + "team": "HMM HR Demo Team", + "scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1005", + "name": "Han Seojun", + "role": "Recruiting Specialist", + "team": "HMM HR Demo Team", + "scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1007", + "name": "Kang Minho", + "role": "HR Coordinator", + "team": "HMM HR Demo Team", + "scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모" } ] } diff --git a/poc4_active_source_20260714/requirements.txt b/poc4_active_source_20260714/requirements.txt index e2cf71c..f9a6b82 100644 --- a/poc4_active_source_20260714/requirements.txt +++ b/poc4_active_source_20260714/requirements.txt @@ -1,5 +1,5 @@ openpyxl>=3.1,<4 +oci>=2.180,<3 oracledb>=2,<4 pandas>=2,<3 streamlit>=1.35,<2 - diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/poc4_active_source_20260714/src/agent_console/presentation.py index 2ea8b72..f561028 100644 --- a/poc4_active_source_20260714/src/agent_console/presentation.py +++ b/poc4_active_source_20260714/src/agent_console/presentation.py @@ -41,6 +41,19 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: div[data-testid="stButton"] > button[kind="primary"] *, div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{ color:#fff !important; -webkit-text-fill-color:#fff !important; }} + /* Streamlit JSON/code components otherwise retain a dark-surface theme while + the console text is forced dark, making MCP details unreadable. */ + [data-testid="stJson"], [data-testid="stJson"] > div, + [data-testid="stCodeBlock"], [data-testid="stCodeBlock"] > div, + [data-testid="stCode"], [data-testid="stCode"] > div {{ + background:#f6f8fa !important; color:var(--console-text) !important; + border-color:var(--console-border) !important; }} + [data-testid="stJson"] *, [data-testid="stCodeBlock"] *, [data-testid="stCode"] *, + [data-testid="stJson"] pre, [data-testid="stCodeBlock"] pre, [data-testid="stCode"] pre {{ + color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; }} + [data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{ + background:#fff !important; border-color:var(--console-border) !important; }} .console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }} .console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }} .console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }} diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py index 679f931..c2f0f87 100644 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ b/poc4_active_source_20260714/tests/test_scenarios.py @@ -45,6 +45,18 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios})) self.assertTrue(all(item.question.strip() for item in scenarios)) + def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None: + path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json" + payload = json.loads(path.read_text(encoding="utf-8")) + presets = payload["presets"] + + self.assertEqual(payload["version"], 2) + self.assertEqual({item["user_id"] for item in presets}, { + "E1001", "E1002", "E1003", "E1005", "E1007" + }) + self.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" for item in presets)) + self.assertTrue(all("token" not in item for item in presets)) + def test_duplicate_id_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "scenarios.json" From d6a558e45a4dbd566d4afe8748ef5dd710a47dbb Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 22 Jul 2026 20:13:17 +0900 Subject: [PATCH 04/18] refs #699: honor MCP tool input schemas --- docs/design/699-hmm-mcp-demo-users/README.md | 2 + .../src/mcp_tool_router.py | 42 +++++++++++-------- .../tests/test_scenarios.py | 37 ++++++++++++++++ 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md index ea4b393..ad582dd 100644 --- a/docs/design/699-hmm-mcp-demo-users/README.md +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -24,6 +24,8 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - 선택한 사용자는 자연어의 ‘나’, ‘내’, ‘우리 팀’을 해석하는 데모 문맥이다. 현재 HMM MCP의 gateway token은 공용이므로 이 선택 자체가 DB 행 수준 권한을 강제한다고 표시하지 않는다. - HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다. +- 호출 인자는 `tools/list`의 schema를 기준으로 생성한다. 기본 도구에도 레거시 `prompt`/`limit`를 + 강제하지 않으며 `search_hr_data`·`search_hr_policy`는 `query`, `resolve_hr_term`은 `term`을 전달한다. ## 런타임 구성 diff --git a/poc4_active_source_20260714/src/mcp_tool_router.py b/poc4_active_source_20260714/src/mcp_tool_router.py index 3e09962..3dfae60 100644 --- a/poc4_active_source_20260714/src/mcp_tool_router.py +++ b/poc4_active_source_20260714/src/mcp_tool_router.py @@ -154,26 +154,32 @@ def build_mcp_tool_arguments( if not isinstance(properties, Mapping): properties = {} - if tool.name == preferred_tool: - return {"prompt": question, "limit": limit} - if "prompt" in properties: - args: dict[str, Any] = {"prompt": question} - if "limit" in properties: - args["limit"] = limit - elif "max_rows" in properties: - args["max_rows"] = limit - return args - if "question" in properties: - args = {"question": question} - if "max_rows" in properties: - args["max_rows"] = limit - elif "limit" in properties: - args["limit"] = limit - return args - if "query" in properties: - args = {"query": question} + # A server's default/preferred tool still has to obey its discovered schema. + # HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape + # makes an otherwise valid tool fail argument validation. + del preferred_tool + input_name = next( + (name for name in ("prompt", "question", "query", "term", "text") if name in properties), + "", + ) + if not input_name: + required = tool.schema.get("required") + if isinstance(required, list): + input_name = next( + ( + str(name) + for name in required + if isinstance(properties.get(str(name)), Mapping) + and properties[str(name)].get("type") == "string" + ), + "", + ) + if input_name: + args: dict[str, Any] = {input_name: question} if "max_evidence" in properties: args["max_evidence"] = min(limit, 10) + elif "max_rows" in properties: + args["max_rows"] = limit elif "limit" in properties: args["limit"] = limit return args diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py index c2f0f87..5ca60be 100644 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ b/poc4_active_source_20260714/tests/test_scenarios.py @@ -8,6 +8,7 @@ from unittest.mock import patch from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios from src.agent_console.profile import load_app_profile +from src.mcp_tool_router import McpTool, build_mcp_tool_arguments class DemoScenarioConfigTest(unittest.TestCase): @@ -74,6 +75,42 @@ class DemoScenarioConfigTest(unittest.TestCase): with self.assertRaises(ScenarioConfigError): load_demo_scenarios(path) + def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None: + tool = McpTool( + name="search_hr_data", + description="", + schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + read_only=True, + ) + + arguments = build_mcp_tool_arguments( + tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data" + ) + + self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"}) + + def test_term_tool_arguments_follow_discovered_term_schema(self) -> None: + tool = McpTool( + name="resolve_hr_term", + description="", + schema={ + "type": "object", + "properties": {"term": {"type": "string"}}, + "required": ["term"], + }, + read_only=True, + ) + + arguments = build_mcp_tool_arguments( + tool, "반차", 50, preferred_tool="search_hr_data" + ) + + self.assertEqual(arguments, {"term": "반차"}) + if __name__ == "__main__": unittest.main() From 62c5fb518c96ec2dca5f09b97fef8198ba79cff0 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 22 Jul 2026 20:16:57 +0900 Subject: [PATCH 05/18] refs #699: record HMM deployment verification --- docs/design/699-hmm-mcp-demo-users/README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md index ad582dd..950ffad 100644 --- a/docs/design/699-hmm-mcp-demo-users/README.md +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -1,6 +1,6 @@ # HMM MCP 데모 사용자 preset (#699) -> 상태: Implementing +> 상태: Verified / Deployed (2026-07-22) > 추적: Redmine #699 / Git 브랜치: `hmm-backoffice` > 대상: `hmm.cloud-handson.com` / `/opt/hmm-poc4` @@ -43,3 +43,14 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e 4. 기존 KB MCP·KB VPD 안내가 HMM HR 질문 생성 경로에 사용되지 않는다. 5. OCI SDK가 `DEFAULT` profile로 GPT-5.4-mini를 실제 호출한다. 6. MCP 호출 상세의 JSON·code가 밝은 테마에서 읽힌다. + +## 배포 검증 + +- `/opt/hmm-poc4/config/vpd_token_presets.json`: E1001, E1002, E1003, E1005, E1007 및 + `HMM_MCP_BEARER_TOKEN` 간접 참조 확인 +- MCP `tools/list`: `search_hr_data`, `resolve_hr_term`, `search_hr_policy` 확인 +- E1001 문맥의 한국어 `search_hr_data` 실제 호출 및 UTF-8 응답 확인 +- `/opt/hmm-poc4/.venv/bin/python`에서 `openai.gpt-5.4-mini` 실제 호출 확인 +- systemd `ExecStart`를 `/opt/hmm-poc4/.venv/bin/python -m streamlit ...`로 고정 +- Chromium dark color-scheme에서 MCP JSON 계산값 확인: + 배경 `rgb(246, 248, 250)`, 글자 `rgb(23, 43, 58)` From eb5105fe7211f2ff65c3603c286affc861ce891b Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 09:40:09 +0900 Subject: [PATCH 06/18] refs #699: preserve HMM policy MCP evidence --- docs/design/699-hmm-mcp-demo-users/README.md | 10 +++ .../apps/poc4/mcp_discovery_ui.py | 24 +++++- .../src/agent_console/presentation.py | 13 +++ poc4_active_source_20260714/src/mcp_result.py | 80 +++++++++++++++++++ .../tests/test_scenarios.py | 68 ++++++++++++++++ 5 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 poc4_active_source_20260714/src/mcp_result.py diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md index 950ffad..5ad35de 100644 --- a/docs/design/699-hmm-mcp-demo-users/README.md +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -26,6 +26,10 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다. - 호출 인자는 `tools/list`의 schema를 기준으로 생성한다. 기본 도구에도 레거시 `prompt`/`limit`를 강제하지 않으며 `search_hr_data`·`search_hr_policy`는 `query`, `resolve_hr_term`은 `term`을 전달한다. +- MCP 호환 서버의 `status/result` 응답에서 `result` 문자열은 정책 본문 근거다. 화면 요약에는 + 길이와 앞부분을 표시하고, 최종 답변 합성에는 제한된 길이의 원문을 전달한다. +- 사용자 ID·역할·팀 문맥은 `search_hr_data`의 ‘나/우리 팀’ 해석에만 사용한다. + `search_hr_policy`와 `resolve_hr_term`에는 원 질문/원 용어만 전달해 벡터·lexical 검색어를 오염시키지 않는다. ## 런타임 구성 @@ -34,6 +38,8 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`는 배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다. - MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다. +- 브라우저의 dark color-scheme과 관계없이 답변 목록, expander header, 보조 버튼도 밝은 배경과 + 어두운 글자색을 사용한다. ## 완료 기준 @@ -54,3 +60,7 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - systemd `ExecStart`를 `/opt/hmm-poc4/.venv/bin/python -m streamlit ...`로 고정 - Chromium dark color-scheme에서 MCP JSON 계산값 확인: 배경 `rgb(246, 248, 250)`, 글자 `rgb(23, 43, 58)` +- 정책 질문 브라우저 검증: 12월 31일 기준 사용촉진조치 시 미사용 연차 이월 없음과 + 입사 2년차부터 차기 발생연차 50% 선사용 제한을 답변했다. +- dark color-scheme 계산값: expander 배경 `rgb(246, 248, 250)`, 보조 버튼 배경 + `rgb(255, 255, 255)`, 답변 목록·expander·버튼 글자 `rgb(23, 43, 58)` diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index b7adfbd..a6a014e 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -46,6 +46,12 @@ from src.mcp_tool_router import ( build_mcp_tool_arguments, route_mcp_tool_across_servers_with_llm, ) +from src.mcp_result import ( + has_actionable_text_result, + status_result_evidence, + status_result_summary, + text_result, +) from src.oci_genai_sdk import ( build_oci_genai_completion_client, temperature_for_model_profile, @@ -4108,7 +4114,7 @@ def _mcp_summary(mcp_result: Any) -> dict[str, Any]: return {"type": type(mcp_result).__name__} payload = _mcp_response_payload(mcp_result) items = _mcp_items(mcp_result) - summary: dict[str, Any] = {} + summary: dict[str, Any] = status_result_summary(mcp_result) for key in ("toolName", "profile", "ordsPath", "generatedSql"): value = mcp_result.get(key) if key in mcp_result else payload.get(key) if value: @@ -4195,6 +4201,8 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str: def _mcp_has_actionable_result(mcp_result: Any) -> bool: if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result): return True + if has_actionable_text_result(mcp_result): + return True payload = _mcp_response_payload(mcp_result) results = payload.get("results") if isinstance(results, list) and results: @@ -4259,7 +4267,10 @@ def _mcp_answer_evidence( if not isinstance(mcp_result, Mapping): return mcp_result payload = _mcp_response_payload(mcp_result) - evidence: dict[str, Any] = {} + evidence: dict[str, Any] = status_result_evidence( + mcp_result, + max_chars=max(3500, min(7000, max_text * 15)), + ) for key in ( "toolName", "profile", @@ -5468,7 +5479,7 @@ def _prepare_hmm_hr_tool_query( """Prepare an HMM HR query without inheriting retired KB/VPD prompt rules.""" fallback = str(question or "").strip() - if tool.name == "resolve_hr_term": + if tool.name in {"resolve_hr_term", "search_hr_policy"}: return fallback contextual_question = _hmm_demo_user_context( fallback, @@ -5646,6 +5657,8 @@ def synthesize_answer( "say that clearly. Treat items_count=0 or results_count=0 as an actual " "zero-row result, not as missing tool delivery. Never claim that an audit " "event exists unless its identifier or audit result is in the evidence. " + "For compatibility MCP tools, the result string contains the factual " + "DOC and EVIDENCE lines; read it as evidence rather than treating it as metadata. " "For cross-source questions, keep structured contract/product facts separate " "from vector clause evidence, then combine only matching identifiers. If one " "source is missing, list confirmed and unconfirmed points separately instead " @@ -5781,6 +5794,11 @@ def fallback_answer_from_mcp( lines.append(f"- {title or '검색 결과'}{suffix}: {body}") else: lines.append(f"- {compact}") + elif text_result(mcp_result): + result = text_result(mcp_result) + lines.append("") + lines.append("MCP 반환 근거:") + lines.append(result[:6000] + ("..." if len(result) > 6000 else "")) elif agent_steps: lines.append(f"Agent는 MCP tool을 {len(agent_steps)}회 호출했습니다.") else: diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/poc4_active_source_20260714/src/agent_console/presentation.py index f561028..2014f3c 100644 --- a/poc4_active_source_20260714/src/agent_console/presentation.py +++ b/poc4_active_source_20260714/src/agent_console/presentation.py @@ -27,6 +27,8 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: [data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span, [data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1, [data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3, + [data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt, + [data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote, [data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea, section[data-testid="stSidebar"] * {{ color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }} @@ -34,7 +36,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: background:#fff !important; border:1px solid var(--console-border) !important; border-radius:4px !important; box-shadow:none !important; }} div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{ + background:#fff !important; color:var(--console-text) !important; + border:1px solid var(--console-border) !important; border-radius:4px !important; box-shadow:none !important; }} + div[data-testid="stButton"] > button *, + div[data-testid="stFormSubmitButton"] > button * {{ + color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }} div[data-testid="stButton"] > button[kind="primary"], div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{ background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }} @@ -54,6 +61,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: -webkit-text-fill-color:var(--console-text) !important; }} [data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{ background:#fff !important; border-color:var(--console-border) !important; }} + [data-testid="stExpander"] summary {{ + background:#f6f8fa !important; color:var(--console-text) !important; + border-color:var(--console-border) !important; }} + [data-testid="stExpander"] summary * {{ + color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; }} .console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }} .console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }} .console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }} diff --git a/poc4_active_source_20260714/src/mcp_result.py b/poc4_active_source_20260714/src/mcp_result.py new file mode 100644 index 0000000..a35ec02 --- /dev/null +++ b/poc4_active_source_20260714/src/mcp_result.py @@ -0,0 +1,80 @@ +"""Pure helpers for MCP result envelopes used by the Streamlit console.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def response_payload(value: Any) -> Mapping[str, Any]: + """Return the business payload from a direct or nested MCP response.""" + + if not isinstance(value, Mapping): + return {} + nested = value.get("response") + return nested if isinstance(nested, Mapping) else value + + +def text_result(value: Any) -> str: + """Return a textual `result` field without stringifying other structures.""" + + result = response_payload(value).get("result") + return result.strip() if isinstance(result, str) else "" + + +def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]: + """Build a safe UI summary for status/result-style compatibility tools.""" + + payload = response_payload(value) + summary: dict[str, Any] = {} + for key in ("status", "success", "error", "errorCode", "errorMessage"): + item = payload.get(key) + if item not in (None, "", []): + summary[key] = item + result = text_result(value) + if result: + summary["result_chars"] = len(result) + summary["result_excerpt"] = result[:excerpt_chars] + ( + "..." if len(result) > excerpt_chars else "" + ) + return summary + + +def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]: + """Preserve bounded textual policy/data evidence for final answer synthesis.""" + + payload = response_payload(value) + evidence: dict[str, Any] = {} + for key in ("status", "success", "error", "errorCode", "errorMessage"): + item = payload.get(key) + if item not in (None, "", []): + evidence[key] = item + result = text_result(value) + if result: + evidence["result"] = result[:max_chars] + ( + "..." if len(result) > max_chars else "" + ) + evidence["result_chars"] = len(result) + return evidence + + +def has_actionable_text_result(value: Any) -> bool: + """Return whether a textual result contains evidence worth stopping on.""" + + result = text_result(value) + if not result: + return False + normalized = " ".join(result.casefold().split()) + return not any( + marker in normalized + for marker in ("no data found", "no evidence found", "error:") + ) + + +__all__ = [ + "has_actionable_text_result", + "response_payload", + "status_result_evidence", + "status_result_summary", + "text_result", +] diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py index 5ca60be..402982c 100644 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ b/poc4_active_source_20260714/tests/test_scenarios.py @@ -9,6 +9,21 @@ from unittest.mock import patch from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios from src.agent_console.profile import load_app_profile from src.mcp_tool_router import McpTool, build_mcp_tool_arguments +from src.mcp_result import ( + has_actionable_text_result, + status_result_evidence, + status_result_summary, +) + + +def _load_poc4_query_helpers(): + """Load the Streamlit entrypoint only when its optional runtime is installed.""" + + try: + from apps.poc4.mcp_discovery_ui import _prepare_hmm_hr_tool_query + except ModuleNotFoundError: + return None + return _prepare_hmm_hr_tool_query class DemoScenarioConfigTest(unittest.TestCase): @@ -38,6 +53,14 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(profile.short_name, "HMM") + def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None: + path = Path(__file__).parents[1] / "src" / "agent_console" / "presentation.py" + source = path.read_text(encoding="utf-8") + + self.assertIn('[data-testid="stAppViewContainer"] li', source) + self.assertIn('[data-testid="stExpander"] summary', source) + self.assertIn('div[data-testid="stButton"] > button', source) + def test_hmm_scenarios_are_enabled_and_unique(self) -> None: path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" scenarios = load_demo_scenarios(path) @@ -111,6 +134,51 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(arguments, {"term": "반차"}) + def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None: + result = { + "status": "success", + "result": ( + "HR_POLICY_SEARCH_RESULT\n" + "EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준" + ), + } + + summary = status_result_summary(result, excerpt_chars=40) + evidence = status_result_evidence(result) + + self.assertEqual(summary["status"], "success") + self.assertGreater(summary["result_chars"], 40) + self.assertIn("KR_Leave_Policy.pdf", evidence["result"]) + self.assertTrue(has_actionable_text_result(result)) + + def test_no_data_text_is_not_actionable(self) -> None: + self.assertFalse( + has_actionable_text_result({"status": "success", "result": "No data found"}) + ) + + @unittest.skipIf(_load_poc4_query_helpers() is None, "Streamlit runtime is optional") + def test_policy_query_does_not_include_demo_user_context(self) -> None: + prepare = _load_poc4_query_helpers() + assert prepare is not None + tool = McpTool( + name="search_hr_policy", + description="Search policy documents", + schema={"properties": {"query": {"type": "string"}}}, + read_only=True, + ) + + query = prepare( + question="연차 휴가 이월 기준과 제한을 알려줘", + tool=tool, + model_profile_key="gpt54_mini_oci", + selected_user_id="E1001", + selected_user_role="HR Team Manager", + selected_user_team="HMM HR Demo Team", + selected_user_scope="팀원 6명 관리", + ) + + self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘") + if __name__ == "__main__": unittest.main() From 9e3840a4d743e10628da17c50cd4a5e3c338e9fb Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 10:43:54 +0900 Subject: [PATCH 07/18] refs #702: externalize HMM structured data catalog --- .../vpd-backoffice-structured-data.conf | 2 + .../702-hmm-backoffice-identity/README.md | 30 ++++- ...07-23-hmm-backoffice-full-browser-audit.md | 60 +++++++++ .../domain/masking/ManagedMaskingPolicy.java | 7 ++ .../structured/StructuredDataCatalog.java | 13 ++ .../structured/StructuredDataTable.java | 10 +- .../mapper/MaskingRuleMapper.java | 6 +- .../service/MaskingPolicySynchronizer.java | 62 +++++----- .../service/MaskingRuleService.java | 9 +- .../service/SchemaMetadataService.java | 18 ++- .../StructuredDataCatalogProvider.java | 115 ++++++++++++++++++ .../service/StructuredDataService.java | 74 ++++++----- .../web/SchemaMetadataController.java | 1 + .../web/StructuredDataController.java | 1 + src/main/resources/application.yml | 3 + .../config/structured-data-catalog.json | 59 +++++++++ .../resources/mapper/MaskingRuleMapper.xml | 41 ++----- .../resources/templates/masking-rules.html | 16 +-- .../resources/templates/operation-status.html | 4 +- .../resources/templates/schema-metadata.html | 18 +-- .../resources/templates/structured-data.html | 15 +-- .../MaskingPolicySynchronizerTest.java | 19 ++- .../service/StructuredDataServiceTest.java | 48 ++++++-- 23 files changed, 495 insertions(+), 136 deletions(-) create mode 100644 deploy/systemd/vpd-backoffice-structured-data.conf create mode 100644 docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java create mode 100644 src/main/resources/config/structured-data-catalog.json diff --git a/deploy/systemd/vpd-backoffice-structured-data.conf b/deploy/systemd/vpd-backoffice-structured-data.conf new file mode 100644 index 0000000..6bfff19 --- /dev/null +++ b/deploy/systemd/vpd-backoffice-structured-data.conf @@ -0,0 +1,2 @@ +[Service] +Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index 43e7439..2420d96 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -1,6 +1,6 @@ # HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702) -> 상태: Implementing +> 상태: Implemented > 추적: Redmine #702 / Git 브랜치: `hmm-backoffice` > 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN` @@ -56,6 +56,22 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS 멱등적으로 준비하고 `CB_VECTOR_*` 조회 호환 뷰를 갱신한다. 기존 물리 `CB_VECTOR_*` 테이블이 존재하는 환경은 덮어쓰지 않는다. +### 재사용 가능한 정형 원장 카탈로그 + +- `/structured-data`와 `/schema-metadata`의 데이터 원본명, Oracle owner, 안내 문구, 최대 조회 건수, + 허용 테이블 목록은 Java·HTML에 하드코딩하지 않는다. +- 기본 HMM 정의는 `src/main/resources/config/structured-data-catalog.json`에 둔다. 배포 환경에서는 + `BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/.../structured-data-catalog.json`으로 외부 + JSON을 지정할 수 있어 다른 회사 PoC에서 애플리케이션 코드를 수정하지 않고 재사용할 수 있다. +- 각 테이블 정의는 `key`, `tableName`, `businessName`, `description`을 기본으로 하고 필요하면 + `previewColumns`, `maskingPolicyName`을 추가한다. 화면 카드, metadata 대상, 실제 SELECT 허용 목록, + ASO 관리 대상은 모두 이 단일 JSON을 기준으로 생성한다. VECTOR/BLOB 등 JDBC 원장 미리보기에 + 부적합한 컬럼은 `previewColumns`에서 제외하되 DB 메타데이터 화면에는 계속 표시한다. +- owner와 table name은 Oracle 단순 식별자 규칙, key는 URL key 규칙으로 시작 시 검증한다. 중복 key, + 중복 table, 빈 목록, 허용 범위를 벗어난 row limit은 기동 실패로 처리해 동적 SQL 범위를 닫는다. +- HMM 기본 카탈로그는 `ADMIN`의 조직, 직원, 휴가 잔여, 휴가 신청, 일별 근태, HR 표준 용어 원장만 + 노출한다. 기존 `KBAIPOC`, `POC_2`, `KB_*` 보험 원장은 HMM 배포 카탈로그에 포함하지 않는다. + ## HMM MCP 및 시스템 설정 - 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은 @@ -88,3 +104,15 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS 애플리케이션은 이전 JAR로 되돌릴 수 있다. 새 `HMM_ACCESS_*` 테이블은 운영 데이터가 생긴 뒤에는 삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다. + +## 2026-07-23 배포·전수 검증 + +- 운영 JSON: `/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json` +- systemd override: `20-structured-data-catalog.conf` +- 자동 테스트: `mvn test` 103건 통과 +- 브라우저 검증: 로그인 세션으로 메뉴 URL 26개 전부 HTTP 200, 오류 alert 0건, `ORA-` 0건, + `KBAIPOC`/`POC_2`/KB 보험원장 표시 0건, page error 0건, console error 0건 +- 정형 원장 실데이터: 조직 1행, 직원 7행, 휴가 잔여 7행, 휴가 신청 8행, 일별 근태 14행, + HR 표준 용어 21행 렌더링 확인 +- 용어 원장의 VECTOR 컬럼은 `previewColumns`에서 제외해 관리자 미리보기에는 사람이 읽을 수 있는 + 표준 코드·명칭·설명·임베딩 시각만 표시하고, DB 메타데이터 관리에서는 전체 컬럼을 유지한다. diff --git a/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md b/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md new file mode 100644 index 0000000..b47fb2f --- /dev/null +++ b/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md @@ -0,0 +1,60 @@ +# HMM 백오피스 전 화면 브라우저 검증 + +- 검증일: 2026-07-23 KST +- 대상: `https://hmm-backoffice.cloud-handson.com` +- 인증: 운영 관리자 로그인 세션 +- 브라우저: Playwright Chromium, 1440×1000 +- 추적: Redmine #702 + +## 판정 기준 + +각 URL을 직접 열어 HTTP 200, 주 제목 렌더링, 오류 alert 부재를 검사했다. 본문에서 `ORA-`, +`데이터 처리 오류`, `Whitelabel Error Page`, `KBAIPOC`, `POC_2`, KB 보험원장 테이블명을 탐지하고, +브라우저 page error와 console error도 수집했다. + +## 메뉴 결과 + +| 영역 | URL | 결과 | +| --- | --- | --- | +| 대시보드 | `/` | PASS | +| 사용자 | `/users` | PASS | +| 접근 그룹 | `/groups` | PASS | +| 역할 | `/roles` | PASS | +| 행 접근 규칙 | `/permissions` | PASS | +| 컬럼 원문 표시 허용 | `/user-masking-rules` | PASS | +| 권한 현황 | `/effective-matrix` | PASS | +| 보호 상태 | `/vpd-policies` | PASS | +| 컬럼 마스킹 | `/masking-rules` | PASS | +| 토큰 발급 | `/tokens` | PASS | +| 접근 검증 | `/probe` | PASS | +| 조회 대상 | `/objects` | PASS | +| 정형 데이터 조회 | `/structured-data` | PASS | +| 조회 연동 | `/ords-handlers` | PASS | +| 지식자료 관리 | `/vector-knowledge` | PASS | +| 대화형 검색 | `/mcp-chatbot` | PASS | +| 검색 해석 | `/mcp-reasoning` | PASS | +| MCP 서비스 | `/mcp-sse` | PASS | +| 연동 점검 | `/mcp-client-demo` | PASS | +| 운영 현황 | `/operation-status` | PASS | +| 행 접근 필터 구조 | `/vpd-filter-runtime` | PASS | +| DB 메타데이터 | `/schema-metadata` | PASS | +| 보안 SQL 스크립트 | `/security-sql-scripts` | PASS | +| 고급 접근 조건 | `/vpd-filter-policies` | PASS | +| 시스템 설정 | `/settings` | PASS | +| DB 준비 상태 | `/settings/database` | PASS | + +총 26개 URL이 모두 통과했다. 오류 alert, KB/POC 잔재, page error, console error는 각각 0건이다. + +## HMM 정형 원장 결과 + +| JSON key | 테이블 | 렌더링 행 | 결과 | +| --- | --- | ---: | --- | +| `teams` | `ADMIN.HMM_ORG_TEAMS` | 1 | PASS | +| `employees` | `ADMIN.HMM_HR_EMPLOYEES` | 7 | PASS | +| `leave-balances` | `ADMIN.HMM_LEAVE_BALANCES` | 7 | PASS | +| `leave-requests` | `ADMIN.HMM_LEAVE_REQUESTS` | 8 | PASS | +| `attendance-daily` | `ADMIN.HMM_ATTENDANCE_DAILY` | 14 | PASS | +| `hr-terms` | `ADMIN.HMM_HR_TERMS` | 21 | PASS | + +`HMM_HR_TERMS.EMBEDDING`은 VECTOR이므로 JSON `previewColumns`에서 제외했다. 이 설정으로 원장 +미리보기는 21개 용어를 정상 표시하며, VECTOR 자체는 용어 검색 기능에서 계속 사용한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java new file mode 100644 index 0000000..6b1959a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java @@ -0,0 +1,7 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +public record ManagedMaskingPolicy( + String objectName, + String policyName +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java new file mode 100644 index 0000000..32bb946 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java @@ -0,0 +1,13 @@ +package com.cloudhandson.vpdbackoffice.domain.structured; + +import java.util.List; + +public record StructuredDataCatalog( + String sourceName, + String owner, + String pageHelp, + String catalogDescription, + int rowLimit, + List tables +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index a247fc5..a8f6e0a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -1,9 +1,17 @@ package com.cloudhandson.vpdbackoffice.domain.structured; +import java.util.List; + public record StructuredDataTable( String key, String tableName, String businessName, - String description + String description, + List previewColumns, + String maskingPolicyName ) { + + public StructuredDataTable(String key, String tableName, String businessName, String description) { + this(key, tableName, businessName, description, List.of(), null); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index ca4edb8..eece38a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -4,6 +4,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; +import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import java.util.List; import org.apache.ibatis.annotations.Mapper; @@ -28,7 +29,10 @@ public interface MaskingRuleMapper { List findColumnRules(); - List findPolicyStatuses(); + List findPolicyStatuses( + @Param("owner") String owner, + @Param("policies") List policies + ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index ce4b4ae..8d21949 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import java.util.ArrayList; import java.util.Collections; @@ -24,37 +25,42 @@ import org.springframework.stereotype.Service; @Service public class MaskingPolicySynchronizer { - private static final String OWNER = "POC_2"; private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); - private static final Map MANAGED_POLICIES = managedPolicyMap(); private final JdbcTemplate jdbcTemplate; private final MaskingRuleMapper mapper; + private final StructuredDataCatalog catalog; + private final Map managedPolicies; - public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) { + public MaskingPolicySynchronizer( + JdbcTemplate jdbcTemplate, + MaskingRuleMapper mapper, + StructuredDataCatalogProvider catalogProvider + ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - } - - private static Map managedPolicyMap() { + this.catalog = catalogProvider.catalog(); Map policies = new LinkedHashMap<>(); - policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT"); - policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT"); - policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT"); - policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT"); - return Collections.unmodifiableMap(policies); + catalog.tables().stream() + .filter(table -> table.maskingPolicyName() != null) + .forEach(table -> policies.put(table.tableName(), table.maskingPolicyName())); + this.managedPolicies = Collections.unmodifiableMap(policies); } public Set managedObjectNames() { - return MANAGED_POLICIES.keySet(); + return managedPolicies.keySet(); + } + + public String owner() { + return catalog.owner(); } public boolean isManagedObject(String objectName) { - return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); + return objectName != null && managedPolicies.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); } - static String managedPolicyName(String objectName) { - return MANAGED_POLICIES.get(objectName); + String managedPolicyName(String objectName) { + return managedPolicies.get(objectName); } /** @@ -67,9 +73,9 @@ public class MaskingPolicySynchronizer { public MaskingPolicySyncResult synchronize() { Map> desiredByObject = new LinkedHashMap<>(); for (ColumnMaskingRule rule : mapper.findColumnRules()) { - if (OWNER.equalsIgnoreCase(rule.owner()) + if (catalog.owner().equalsIgnoreCase(rule.owner()) && rule.ruleEnabled() - && MANAGED_POLICIES.containsKey(rule.objectName())) { + && managedPolicies.containsKey(rule.objectName())) { desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); } } @@ -79,7 +85,7 @@ public class MaskingPolicySynchronizer { int addedColumns = 0; int modifiedColumns = 0; int droppedColumns = 0; - for (Map.Entry policy : MANAGED_POLICIES.entrySet()) { + for (Map.Entry policy : managedPolicies.entrySet()) { String objectName = policy.getKey(); String policyName = policy.getValue(); List desired = desiredByObject.getOrDefault(objectName, List.of()); @@ -141,7 +147,7 @@ public class MaskingPolicySynchronizer { SELECT enable FROM redaction_policies WHERE object_owner = ? AND object_name = ? AND policy_name = ? - """, String.class, OWNER, objectName, policyName); + """, String.class, catalog.owner(), objectName, policyName); return statuses.isEmpty() ? null : statuses.getFirst(); } @@ -150,7 +156,7 @@ public class MaskingPolicySynchronizer { SELECT column_name FROM redaction_columns WHERE object_owner = ? AND object_name = ? - """, String.class, OWNER, objectName).stream() + """, String.class, catalog.owner(), objectName).stream() .map(this::requiredColumnName) .toList(); } @@ -160,7 +166,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, catalog.owner(), objectName, policyName); } private void enablePolicy(String objectName, String policyName) { @@ -168,7 +174,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, catalog.owner(), objectName, policyName); } private void dropColumn(String objectName, String policyName, String columnName) { @@ -179,7 +185,7 @@ public class MaskingPolicySynchronizer { action => DBMS_REDACT.DROP_COLUMN, column_name => ? ); END; - """, OWNER, objectName, policyName, columnName); + """, catalog.owner(), objectName, policyName, columnName); } private void addPolicy( @@ -251,9 +257,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } return; } @@ -276,9 +282,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(actionConstant, functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } } @@ -314,7 +320,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, OWNER, objectName, columnName, expressionName); + """, catalog.owner(), objectName, columnName, expressionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index 05335de..92621fd 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; @@ -55,9 +56,13 @@ public class MaskingRuleService { return mapper.findColumnRules(); } - /** Reads the actual Oracle Data Redaction state for the three managed KB objects. */ + /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */ public List findPolicyStatuses() { - return mapper.findPolicyStatuses(); + List policies = maskingPolicySynchronizer.managedObjectNames().stream() + .map(objectName -> new ManagedMaskingPolicy( + objectName, maskingPolicySynchronizer.managedPolicyName(objectName))) + .toList(); + return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies); } public Set managedObjectNames() { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index 695beff..cd00697 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -18,7 +19,6 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SchemaMetadataService { - private static final String OWNER = "POC_2"; private static final int MAX_COMMENT_LENGTH = 4000; private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000; private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); @@ -35,6 +35,10 @@ public class SchemaMetadataService { return structuredDataService.tables(); } + public StructuredDataCatalog catalog() { + return structuredDataService.catalog(); + } + public String defaultKey() { return structuredDataService.defaultKey(); } @@ -111,7 +115,7 @@ public class SchemaMetadataService { FROM all_tab_comments WHERE owner = ? AND table_name = ? - """, (rs, rowNum) -> rs.getString(1), OWNER, tableName); + """, (rs, rowNum) -> rs.getString(1), owner(), tableName); return values.isEmpty() ? "" : values.getFirst(); } @@ -146,7 +150,7 @@ public class SchemaMetadataService { "Y".equalsIgnoreCase(rs.getString("nullable")), nullToEmpty(rs.getString("comments")), annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of()) - ), OWNER, tableName); + ), owner(), tableName); } private Map> annotationsByTarget(String tableName) { @@ -216,7 +220,7 @@ public class SchemaMetadataService { WHERE owner = ? AND table_name = ? AND column_name = ? - """, Integer.class, OWNER, tableName, column); + """, Integer.class, owner(), tableName, column); if (count == null || count == 0) { throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); } @@ -243,7 +247,11 @@ public class SchemaMetadataService { } private String qualifiedTable(String tableName) { - return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name")); + return quoteName(owner()) + "." + quoteName(requireSimpleName(tableName, "table name")); + } + + private String owner() { + return structuredDataService.owner(); } private String quoteName(String value) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java new file mode 100644 index 0000000..4d0166c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java @@ -0,0 +1,115 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +@Component +public class StructuredDataCatalogProvider { + + private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + private static final Pattern TABLE_KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + private static final int MAX_ROW_LIMIT = 500; + + private final StructuredDataCatalog catalog; + + public StructuredDataCatalogProvider( + ObjectMapper objectMapper, + @Value("${backoffice.structured-data.catalog-location:classpath:/config/structured-data-catalog.json}") + Resource catalogResource + ) { + try (var input = catalogResource.getInputStream()) { + this.catalog = validate(objectMapper.readValue(input, StructuredDataCatalog.class)); + } catch (IOException exception) { + throw new IllegalStateException( + "정형 데이터 카탈로그 JSON을 읽을 수 없습니다: " + catalogResource.getDescription(), exception); + } + } + + public StructuredDataCatalog catalog() { + return catalog; + } + + private StructuredDataCatalog validate(StructuredDataCatalog source) { + if (source == null) { + throw new IllegalStateException("정형 데이터 카탈로그가 비어 있습니다."); + } + String sourceName = requireText(source.sourceName(), "sourceName"); + String owner = requireOracleName(source.owner(), "owner"); + String pageHelp = requireText(source.pageHelp(), "pageHelp"); + String catalogDescription = requireText(source.catalogDescription(), "catalogDescription"); + if (source.rowLimit() < 1 || source.rowLimit() > MAX_ROW_LIMIT) { + throw new IllegalStateException("정형 데이터 카탈로그 rowLimit은 1~" + MAX_ROW_LIMIT + " 범위여야 합니다."); + } + if (source.tables() == null || source.tables().isEmpty()) { + throw new IllegalStateException("정형 데이터 카탈로그에는 테이블이 한 개 이상 필요합니다."); + } + + Set keys = new HashSet<>(); + Set tableNames = new HashSet<>(); + List tables = source.tables().stream().map(table -> { + if (table == null) { + throw new IllegalStateException("정형 데이터 카탈로그에 null 테이블 정의가 있습니다."); + } + String key = requireKey(table.key()); + String tableName = requireOracleName(table.tableName(), "tableName"); + if (!keys.add(key)) { + throw new IllegalStateException("정형 데이터 카탈로그 key가 중복됩니다: " + key); + } + if (!tableNames.add(tableName)) { + throw new IllegalStateException("정형 데이터 카탈로그 tableName이 중복됩니다: " + tableName); + } + List previewColumns = table.previewColumns() == null + ? List.of() + : table.previewColumns().stream() + .map(column -> requireOracleName(column, "previewColumns")) + .distinct() + .toList(); + String maskingPolicyName = table.maskingPolicyName() == null || table.maskingPolicyName().isBlank() + ? null + : requireOracleName(table.maskingPolicyName(), "maskingPolicyName"); + return new StructuredDataTable( + key, + tableName, + requireText(table.businessName(), "businessName"), + requireText(table.description(), "description"), + List.copyOf(previewColumns), + maskingPolicyName); + }).toList(); + + return new StructuredDataCatalog( + sourceName, owner, pageHelp, catalogDescription, source.rowLimit(), List.copyOf(tables)); + } + + private String requireKey(String value) { + String normalized = requireText(value, "key").toLowerCase(Locale.ROOT); + if (!TABLE_KEY.matcher(normalized).matches()) { + throw new IllegalStateException("정형 데이터 카탈로그 key 형식이 올바르지 않습니다: " + value); + } + return normalized; + } + + private String requireOracleName(String value, String field) { + String normalized = requireText(value, field).toUpperCase(Locale.ROOT); + if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) { + throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 형식이 올바르지 않습니다: " + value); + } + return normalized; + } + + private String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 값은 필수입니다."); + } + return value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index 9acc974..d13ad92 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -1,5 +1,6 @@ package com.cloudhandson.vpdbackoffice.service; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.List; @@ -11,33 +12,35 @@ import org.springframework.stereotype.Service; @Service public class StructuredDataService { - private static final String OWNER = "POC_2"; - private static final int ROW_LIMIT = 50; - private static final List TABLES = List.of( - new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"), - new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"), - new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"), - new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"), - new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"), - new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"), - new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑")); - private final JdbcTemplate jdbcTemplate; + private final StructuredDataCatalog catalog; - public StructuredDataService(JdbcTemplate jdbcTemplate) { + public StructuredDataService( + JdbcTemplate jdbcTemplate, + StructuredDataCatalogProvider catalogProvider + ) { this.jdbcTemplate = jdbcTemplate; + this.catalog = catalogProvider.catalog(); + } + + public StructuredDataCatalog catalog() { + return catalog; + } + + public String owner() { + return catalog.owner(); } public List tables() { - return TABLES; + return catalog.tables(); } public String defaultKey() { - return TABLES.getFirst().key(); + return catalog.tables().getFirst().key(); } public StructuredDataTable requireTable(String key) { - return TABLES.stream() + return catalog.tables().stream() .filter(table -> table.key().equals(key)) .findFirst() .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); @@ -53,17 +56,22 @@ public class StructuredDataService { WHERE owner = ? AND table_name = ? ORDER BY column_id - """, - (resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName()); + """, + (resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName()); if (columns.isEmpty()) { throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); } + List previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns(); + if (!columns.containsAll(previewColumns)) { + throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다."); + } List> rows = jdbcTemplate.queryForList( - previewSql(table), ROW_LIMIT); - return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); + previewSql(table, previewColumns), catalog.rowLimit()); + return new StructuredDataPreview(table, previewColumns, rows, catalog.rowLimit()); } catch (DataAccessException exception) { - throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요."); + throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.sourceName() + "의 " + + catalog.owner() + " 조회 권한과 대상 테이블 상태를 확인하세요."); } } @@ -71,16 +79,20 @@ public class StructuredDataService { * The table is selected from a closed application whitelist, so the query * text remains fixed and no request value can become a SQL identifier. */ - private String previewSql(StructuredDataTable table) { - return switch (table.key()) { - case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?"; - case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?"; - case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?"; - case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?"; - case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?"; - case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?"; - case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?"; - default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); - }; + String previewSql(StructuredDataTable table) { + return previewSql(table, table.previewColumns()); + } + + private String previewSql(StructuredDataTable table, List previewColumns) { + StructuredDataTable approved = requireTable(table.key()); + if (!approved.tableName().equals(table.tableName())) { + throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); + } + String projection = previewColumns == null || previewColumns.isEmpty() + ? "*" + : previewColumns.stream().map(column -> "\"" + column + "\"") + .reduce((left, right) -> left + ", " + right).orElseThrow(); + return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName() + + "\" WHERE ROWNUM <= ?"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java index 2c518e5..f6022bb 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java @@ -22,6 +22,7 @@ public class SchemaMetadataController { @GetMapping("/schema-metadata") public String schemaMetadata(@RequestParam(required = false) String table, Model model) { String selectedKey = table == null || table.isBlank() ? schemaMetadataService.defaultKey() : table; + model.addAttribute("catalog", schemaMetadataService.catalog()); model.addAttribute("tables", schemaMetadataService.tables()); model.addAttribute("selectedKey", selectedKey); try { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java index 159095e..4486109 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java @@ -22,6 +22,7 @@ public class StructuredDataController { Model model ) { String selectedKey = table == null || table.isBlank() ? structuredDataService.defaultKey() : table; + model.addAttribute("catalog", structuredDataService.catalog()); model.addAttribute("tables", structuredDataService.tables()); model.addAttribute("selectedKey", selectedKey); try { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4bed6d3..eaec76e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,9 @@ server: same-site: lax backoffice: + structured-data: + # Override with file:/... JSON to reuse the application for another company/data model. + catalog-location: ${BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION:classpath:/config/structured-data-catalog.json} security: admin-user: ${BACKOFFICE_ADMIN_USER:admin} admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin} diff --git a/src/main/resources/config/structured-data-catalog.json b/src/main/resources/config/structured-data-catalog.json new file mode 100644 index 0000000..8a83dcd --- /dev/null +++ b/src/main/resources/config/structured-data-catalog.json @@ -0,0 +1,59 @@ +{ + "sourceName": "HMMAIPOC", + "owner": "ADMIN", + "pageHelp": "HMM HR 데모의 승인된 조직·직원·휴가·근태 원장을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.", + "catalogDescription": "HMMAIPOC의 ADMIN 스키마에서 승인된 HMM HR 정형 테이블만 표시합니다.", + "rowLimit": 50, + "tables": [ + { + "key": "teams", + "tableName": "HMM_ORG_TEAMS", + "businessName": "조직 원장", + "description": "HMM HR 조직·팀 기본정보" + }, + { + "key": "employees", + "tableName": "HMM_HR_EMPLOYEES", + "businessName": "직원 원장", + "description": "직원·매니저·소속팀 정보", + "maskingPolicyName": "HMM_EMPLOYEE_PII_REDACT" + }, + { + "key": "leave-balances", + "tableName": "HMM_LEAVE_BALANCES", + "businessName": "휴가 잔여 원장", + "description": "직원별 연도·휴가 유형별 부여·사용·잔여 일수", + "maskingPolicyName": "HMM_LEAVE_BALANCE_REDACT" + }, + { + "key": "leave-requests", + "tableName": "HMM_LEAVE_REQUESTS", + "businessName": "휴가 신청 원장", + "description": "직원별 휴가 신청·승인 상태와 기간", + "maskingPolicyName": "HMM_LEAVE_REQUEST_REDACT" + }, + { + "key": "attendance-daily", + "tableName": "HMM_ATTENDANCE_DAILY", + "businessName": "일별 근태 원장", + "description": "직원별 출퇴근·근무 상태와 근무 시간", + "maskingPolicyName": "HMM_ATTENDANCE_REDACT" + }, + { + "key": "hr-terms", + "tableName": "HMM_HR_TERMS", + "businessName": "HR 표준 용어 원장", + "description": "휴가·근태 표준 코드, 명칭과 유사 표현", + "previewColumns": [ + "TERM_ID", + "TERM_CODE", + "TERM_KIND", + "CANONICAL_NAME", + "TERM_NAME", + "IS_CANONICAL", + "DESCRIPTION", + "EMBEDDED_AT" + ] + } + ] +} diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/src/main/resources/mapper/MaskingRuleMapper.xml index 82b1ac4..6fd2bdc 100644 --- a/src/main/resources/mapper/MaskingRuleMapper.xml +++ b/src/main/resources/mapper/MaskingRuleMapper.xml @@ -68,10 +68,11 @@ --> diff --git a/src/main/resources/templates/masking-rules.html b/src/main/resources/templates/masking-rules.html index 2ecd427..acfcc89 100644 --- a/src/main/resources/templates/masking-rules.html +++ b/src/main/resources/templates/masking-rules.html @@ -108,8 +108,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼레거시 VPD 컬럼 제어DB 정책 활성상태 판단 - POC_2.KB_CUSTOMERS - KB_CUSTOMER_PII_REDACT + ADMIN.HMM_HR_EMPLOYEES + HMM_EMPLOYEE_PII_REDACT 0 0 없음 @@ -142,12 +142,12 @@

컬럼 마스킹 규칙 등록

-

업무용 이름을 붙여 템플릿을 재사용합니다. 예: KB_RRN_STANDARD.

+

업무용 이름을 붙여 템플릿을 재사용합니다. 예: HMM_EMAIL_STANDARD.

@@ -253,7 +253,7 @@ 대상 컬럼규칙템플릿백오피스 설정DB ASO 적용 상태 - POC_2.KB_CUSTOMERS.RRN_MASKED + ADMIN.HMM_HR_EMPLOYEES.EMAIL 주민번호 기본 마스킹 주민등록번호 부분 마스킹 기본 규칙 연결됨 diff --git a/src/main/resources/templates/operation-status.html b/src/main/resources/templates/operation-status.html index c3d0507..37b11f2 100644 --- a/src/main/resources/templates/operation-status.html +++ b/src/main/resources/templates/operation-status.html @@ -159,8 +159,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼상태확인 결과 - POC_2.KB_CLAIMS - KB_CLAIM_AMOUNT_REDACT + ADMIN.HMM_HR_EMPLOYEES + HMM_EMPLOYEE_PII_REDACT 0 0 적용됨 diff --git a/src/main/resources/templates/schema-metadata.html b/src/main/resources/templates/schema-metadata.html index 79a710f..11ad0fc 100644 --- a/src/main/resources/templates/schema-metadata.html +++ b/src/main/resources/templates/schema-metadata.html @@ -10,10 +10,10 @@
도움말

- POC_2 KB 업무 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다. + OWNER의 승인된 업무 테이블 table/column comment와 Oracle annotation을 조회·수정합니다. Select AI profile의 comments=true, annotations=true 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.

-

임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.

+

임의 스키마나 임의 테이블은 수정하지 않고, JSON 카탈로그가 승인한 업무 테이블만 대상으로 합니다.

@@ -24,18 +24,18 @@

테이블 선택

-

정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.

+

승인된 업무 원장만 표시합니다.

- 7 + 6
@@ -44,9 +44,9 @@
TABLE -

계약원장

+

직원 원장

- POC_2.KB_CONTRACTS + OWNER.TABLE_NAME · 설명

diff --git a/src/main/resources/templates/structured-data.html b/src/main/resources/templates/structured-data.html index 067176a..9e65b55 100644 --- a/src/main/resources/templates/structured-data.html +++ b/src/main/resources/templates/structured-data.html @@ -8,7 +8,8 @@

정형 데이터 조회

도움말 -

보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.

+

승인된 업무 원장만 읽기 전용으로 조회합니다.

+

한 번에 최대 50건까지만 표시합니다.

@@ -20,7 +21,7 @@

조회할 원장 선택

-

KBAIPOC의 POC_2 스키마에서 승인된 정형 테이블만 표시합니다.

+

승인된 정형 테이블만 표시합니다.

@@ -28,9 +29,9 @@ class="structured-table-card" th:classappend="${entry.key() == selectedKey} ? ' is-selected'" th:href="@{/structured-data(table=${entry.key()})}"> - 고객원장 - KB_CUSTOMERS - 고객 기본정보 + 직원 원장 + HMM_HR_EMPLOYEES + 직원·조직 정보
@@ -40,9 +41,9 @@
-

고객원장

+

직원 원장

- POC_2.KB_CUSTOMERS + OWNER.TABLE_NAME · 최대 50건

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java index a8ece9b..c91b0c4 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java @@ -1,8 +1,13 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; class MaskingPolicySynchronizerTest { @@ -23,8 +28,16 @@ class MaskingPolicySynchronizerTest { } @Test - void kbContractsPremiumPolicyIsManagedByBackoffice() { - assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS")) - .isEqualTo("KB_CONTRACT_PREMIUM_REDACT"); + void hmmEmployeePolicyComesFromTheSharedJsonCatalog() { + var catalogProvider = new StructuredDataCatalogProvider( + new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json")); + var synchronizer = new MaskingPolicySynchronizer( + mock(JdbcTemplate.class), mock(MaskingRuleMapper.class), catalogProvider); + + assertThat(synchronizer.owner()).isEqualTo("ADMIN"); + assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES")) + .isEqualTo("HMM_EMPLOYEE_PII_REDACT"); + assertThat(synchronizer.managedObjectNames()) + .doesNotContain("KB_CUSTOMERS", "KB_CLAIMS", "KB_CONTRACTS"); } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java index 8b549fa..8dd669a 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java @@ -4,25 +4,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; class StructuredDataServiceTest { - private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); + private final StructuredDataCatalogProvider catalogProvider = new StructuredDataCatalogProvider( + new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json")); + private final StructuredDataService service = new StructuredDataService( + mock(JdbcTemplate.class), catalogProvider); @Test - void exposesOnlyTheSevenApprovedStructuredTables() { + void exposesOnlyTheHMMTablesFromTheJsonCatalog() { + assertThat(service.catalog().sourceName()).isEqualTo("HMMAIPOC"); + assertThat(service.owner()).isEqualTo("ADMIN"); assertThat(service.tables()) .extracting(table -> table.tableName()) .containsExactly( - "KB_CUSTOMERS", - "KB_PRODUCTS", - "KB_CONTRACTS", - "KB_COVERAGES", - "KB_CLAIMS", - "KB_EXTERNAL_HOLDINGS", - "KB_STAKEHOLDERS"); + "HMM_ORG_TEAMS", + "HMM_HR_EMPLOYEES", + "HMM_LEAVE_BALANCES", + "HMM_LEAVE_REQUESTS", + "HMM_ATTENDANCE_DAILY", + "HMM_HR_TERMS"); + assertThat(service.previewSql(service.requireTable("employees"))) + .isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?"); } @Test @@ -31,4 +40,25 @@ class StructuredDataServiceTest { .isInstanceOf(AppException.class) .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); } + + @Test + void rejectsUnsafeOracleIdentifiersInAnExternalCatalog() { + String unsafeJson = """ + { + "sourceName": "demo", + "owner": "ADMIN; DROP USER X", + "pageHelp": "help", + "catalogDescription": "description", + "rowLimit": 10, + "tables": [ + {"key":"employees","tableName":"EMPLOYEES","businessName":"직원","description":"직원"} + ] + } + """; + + assertThatThrownBy(() -> new StructuredDataCatalogProvider( + new ObjectMapper(), new ByteArrayResource(unsafeJson.getBytes()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("owner 형식이 올바르지 않습니다"); + } } From a495e7a96286a7e3219fd66ba4602b74bb62130e Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 12:51:40 +0900 Subject: [PATCH 08/18] refs #699: align HMM portal audit experience --- docs/design/699-hmm-mcp-demo-users/README.md | 40 +- ...026-07-23-hmm-portal-full-browser-audit.md | 49 ++ .../apps/poc4/mcp_discovery_ui.py | 451 +++--------------- .../deploy/systemd/poc4-streamlit-audit.conf | 2 + .../src/agent_console/audit.py | 190 ++++++++ .../src/agent_console/presentation.py | 20 + .../tests/test_scenarios.py | 19 + 7 files changed, 393 insertions(+), 378 deletions(-) create mode 100644 docs/reports/2026-07-23-hmm-portal-full-browser-audit.md create mode 100644 poc4_active_source_20260714/deploy/systemd/poc4-streamlit-audit.conf create mode 100644 poc4_active_source_20260714/src/agent_console/audit.py diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md index 5ad35de..3670007 100644 --- a/docs/design/699-hmm-mcp-demo-users/README.md +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -1,6 +1,6 @@ # HMM MCP 데모 사용자 preset (#699) -> 상태: Verified / Deployed (2026-07-22) +> 상태: Verified / Deployed (2026-07-23) > 추적: Redmine #699 / Git 브랜치: `hmm-backoffice` > 대상: `hmm.cloud-handson.com` / `/opt/hmm-poc4` @@ -64,3 +64,41 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e 입사 2년차부터 차기 발생연차 50% 선사용 제한을 답변했다. - dark color-scheme 계산값: expander 배경 `rgb(246, 248, 250)`, 보조 버튼 배경 `rgb(255, 255, 255)`, 답변 목록·expander·버튼 글자 `rgb(23, 43, 58)` + +## 전 화면 브라우저 검증 기준 (2026-07-23) + +다음 화면과 패널을 각각 실제 Chromium에서 열어 렌더링, 오류 alert, 핵심 조작, 색상 대비를 확인한다. + +1. 로그인: 입력 필드, 로그인 유지, 실패 안내, 정상 로그인, 로그아웃 +2. 아키텍처: 운영 구조와 질의 처리 흐름 +3. 시나리오: 샘플 선택, 질문 입력, 저장된 답변, MCP 상세 +4. 감사로그: HMM 접근 이벤트 유형·상태·기간 필터, 지표와 이벤트 표 +5. 보안관리: 업무 프로세스와 `hmm-backoffice` 운영 포털 링크 +6. 사이드바: 데모 사용자 5명, 대화 검색·세션, 대화 관리, MCP 고급 설정 + +페이지 오류, HTTP 4xx/5xx, 흰 배경의 흰 글자, 검은 배경의 검은 글자, 가로 overflow가 없어야 한다. + +### HMM 감사로그 데이터 원천 + +- 포털 감사로그 탭은 KB PoC의 `POC_2` FGA 카탈로그를 조회하지 않는다. +- DB 접속값은 `POC4_AUDIT_DB_USER`, `POC4_AUDIT_DB_PASSWORD`, `POC4_AUDIT_DSN`으로 + 분리하고 Git에 값을 저장하지 않는다. ADB가 TLS 서버 인증을 허용하므로 운영 환경은 1521 TLS + 접속 기술자를 사용해 Wallet PEM 암호 입력 대기를 제거한다. +- 상호 TLS가 필요한 다른 환경에서는 `POC4_AUDIT_WALLET_DIR`와 + `POC4_AUDIT_WALLET_PASSWORD`를 함께 지정한다. Wallet 암호 없이 단순 TNS alias만 지정하는 + 불완전한 설정은 시작 시 안전한 오류로 처리한다. +- 이벤트 원천은 `ADMIN.HMM_ACCESS_AUDIT`이다. 이벤트 유형, 성공/실패, 대상 key/object, 처리 행 수, + 오류 코드와 메시지를 최근 순으로 표시한다. +- 비활성 Streamlit tab의 글자·배경색도 공통 presentation CSS에서 명시해 OS dark color-scheme과 + 무관하게 WCAG AA 수준의 대비를 유지한다. + +### 2026-07-23 전수검사 결과 + +- 실제 Chromium에서 로그인·아키텍처·시나리오·감사로그·보안관리 5개 화면을 각각 열었다. +- 필수 문구 누락 0, 낮은 색상 대비 0, 가로 overflow 0이다. +- 브라우저 page error 0, console error 0, HTTP 오류 0이다. +- 데모 사용자 5명 선택·복원, 대화 검색·JSON 다운로드, MCP discovery cache 갱신을 확인했다. +- 감사로그 필터 2개, 새로고침, 상세 토글이 렌더링되고 `ADMIN.HMM_ACCESS_AUDIT` 0건 상태를 + 정상적인 empty state로 표시한다. +- 보안관리의 `https://hmm-backoffice.cloud-handson.com/` 링크는 HTTP 200이다. +- 상세 증적은 `docs/reports/2026-07-23-hmm-portal-full-browser-audit.md`에 기록한다. diff --git a/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md b/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md new file mode 100644 index 0000000..b499390 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md @@ -0,0 +1,49 @@ +# HMM 포털 전 화면 브라우저 전수검사 + +- 실행일: 2026-07-23 (Asia/Seoul) +- 대상: `https://hmm.cloud-handson.com/` +- 브라우저: Chromium, 1440×1100, OS dark color-scheme 모의 +- 추적: Redmine #699 / branch `hmm-backoffice` + +## 결과 + +| 화면 | 핵심 검증 | 결과 | +|---|---|---| +| 로그인 | 입력 2개, 로그인 유지, 실패 피드백, 서명 로그인, 로그아웃 | PASS | +| 아키텍처 | 운영 아키텍처, 처리 흐름, AI/MCP, 데이터 보안 | PASS | +| 시나리오 | 질문 입력, 샘플, 전송, 결과 empty state | PASS | +| 감사로그 | HMM 이벤트·상태 필터, 기간·건수, 새로고침, 상세 토글 | PASS | +| 보안관리 | 업무 프로세스, 백오피스 링크 및 HTTP 200 | PASS | +| 사이드바 | 데모 사용자 5명, 전환·복원, 검색, 다운로드, MCP cache | PASS | + +## 정량 결과 + +- 검사 화면: 5 +- 필수 문구 누락: 0 +- 낮은 색상 대비: 0 +- 가로 overflow: 0 +- 브라우저 page error: 0 +- console error: 0 +- HTTP 4xx/5xx: 0 + +시나리오의 “아직 저장된 대화가 없습니다.”와 감사로그의 “선택한 조건에 해당하는 HMM 접근 관리 +이벤트가 없습니다.”는 오류가 아니라 현재 데이터 0건을 설명하는 정상 empty state다. + +## 이번에 제거한 결함 + +1. 감사로그가 KB PoC의 `POC_2` FGA 메타데이터를 조회하던 경로를 제거했다. +2. 감사 원천을 `ADMIN.HMM_ACCESS_AUDIT`으로 교체하고 렌더러를 `src/agent_console/audit.py`로 + 분리했다. +3. Python Thin driver가 암호화 `ewallet.pem`의 암호를 대화형으로 기다리던 연결을 ADB TLS 1521 + 서버 인증 풀로 교체했다. +4. Streamlit 최신 DOM의 `data-testid="stTab"`을 공통 CSS에서 직접 처리해 dark color-scheme에서 + 비활성 탭이 흰색으로 렌더링되던 문제를 제거했다. + +## 운영 배포 + +- 애플리케이션: `/opt/hmm-poc4` +- 서비스: `poc4-streamlit.service` (active) +- 감사 DB 환경 파일: `/etc/hmm-poc4-audit.env` (root-only) +- systemd drop-in: `/etc/systemd/system/poc4-streamlit.service.d/20-audit.conf` + +DB 사용자·암호·접속 기술자 원문은 이 보고서와 Git에 기록하지 않는다. diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index a6a014e..1ccb827 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -62,6 +62,7 @@ from src.agent_console.presentation import ( render_console_header, render_login_brand, ) +from src.agent_console.audit import render_hmm_audit_tab from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios @@ -93,10 +94,16 @@ PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 -AUDIT_SCHEMA = "POC_2" AUDIT_DB_ENV_FILE = Path( - os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env") + os.environ.get("POC4_AUDIT_DB_ENV_FILE", str(ENV_FILE)) ).expanduser() +DEFAULT_AUDIT_DB_DSN = ( + "(description=(retry_count=3)(retry_delay=1)" + "(address=(protocol=tcps)(port=1521)(host=adb.ap-seoul-1.oraclecloud.com))" + "(connect_data=(service_name=" + "yh0olybn5pqce4n_hmmaipoc_high.adb.oraclecloud.com))" + "(security=(ssl_server_dn_match=yes)))" +) _OPAQUE_BEARER = re.compile(r"^[\x21-\x7e]{1,4096}$") KB_THEME_CSS = """ + + +
+
HMM
+

{html.escape(config.login_title)}

+

{html.escape(config.login_description)}

+ {error_html} +
+ + + + + +
+ +
+ +""".encode("utf-8") + + +def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]: + codec = SessionTokenCodec(config.cookie_secret) + limiter = LoginAttemptLimiter() + + class PortalAuthHandler(BaseHTTPRequestHandler): + server_version = "HMMPortalAuth/1.0" + sys_version = "" + + def do_HEAD(self) -> None: + self._route(send_body=False) + + def do_GET(self) -> None: + self._route(send_body=True) + + def do_POST(self) -> None: + path = urlsplit(self.path).path + if path == "/auth/login": + self._login() + elif path == "/auth/logout": + self._logout() + else: + self._send_text(HTTPStatus.NOT_FOUND, "Not found") + + def _route(self, send_body: bool) -> None: + path = urlsplit(self.path).path + if path == "/auth/check": + self._check() + elif path == "/auth/login": + self._show_login(send_body=send_body) + elif path == "/auth/logout": + self._logout() + elif path == "/auth/healthz": + self._send_text(HTTPStatus.OK, "ok", send_body=send_body) + else: + self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body) + + def _check(self) -> None: + session = self._session() + if session is None: + self._send_empty(HTTPStatus.UNAUTHORIZED) + return + self.send_response(HTTPStatus.NO_CONTENT) + self._security_headers() + self.send_header("X-Auth-User", session.username) + self.send_header("X-Auth-Expires", str(session.expires_at)) + self.end_headers() + + def _show_login(self, send_body: bool = True, error: str = "") -> None: + if self._session() is not None and not error: + self._redirect("/") + return + csrf_value = secrets.token_urlsafe(32) + body = _login_page(config, csrf_value, error) + self.send_response(HTTPStatus.OK) + self._security_headers() + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Set-Cookie", csrf_cookie_header(csrf_value)) + self.end_headers() + if send_body: + self.wfile.write(body) + + def _login(self) -> None: + client_key = self._client_key() + if limiter.blocked(client_key): + self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.") + return + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + content_length = 0 + if not 1 <= content_length <= MAX_REQUEST_BYTES: + self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request") + return + raw_body = self.rfile.read(content_length) + try: + form = parse_qs( + raw_body.decode("utf-8"), + keep_blank_values=True, + strict_parsing=False, + max_num_fields=8, + ) + except (UnicodeDecodeError, ValueError): + self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request") + return + username = _form_value(form, "username") + password = _form_value(form, "password") + csrf_form = _form_value(form, "csrf") + csrf_cookie = _cookie_value( + self.headers.get("Cookie", ""), CSRF_COOKIE_NAME + ) + if ( + not csrf_form + or not csrf_cookie + or not hmac.compare_digest(csrf_form, csrf_cookie) + ): + self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request") + return + valid_credentials = ( + len(username) <= 80 + and len(password) <= MAX_FIELD_CHARS + and hmac.compare_digest(username.strip(), config.username) + and password_matches(password, config.password_pbkdf2) + ) + if not valid_credentials: + limiter.record_failure(client_key) + self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.") + return + limiter.reset(client_key) + remember = _form_value(form, "remember") == "yes" + lifetime = ( + config.remember_seconds if remember else config.session_seconds + ) + token = codec.issue(config.username, lifetime) + self.send_response(HTTPStatus.SEE_OTHER) + self._security_headers() + self.send_header("Location", "/") + self.send_header( + "Set-Cookie", + session_cookie_header(token, lifetime if remember else None), + ) + self.send_header("Set-Cookie", clear_csrf_cookie_header()) + self.end_headers() + + def _logout(self) -> None: + self.send_response(HTTPStatus.SEE_OTHER) + self._security_headers() + self.send_header("Location", "/auth/login") + self.send_header("Set-Cookie", clear_session_cookie_header()) + self.send_header("Set-Cookie", clear_csrf_cookie_header()) + self.end_headers() + + def _session(self) -> AuthenticatedSession | None: + token = _cookie_value( + self.headers.get("Cookie", ""), SESSION_COOKIE_NAME + ) + return codec.verify(token, config.username) + + def _client_key(self) -> str: + forwarded = self.headers.get("X-Real-IP", "").strip() + return forwarded or self.client_address[0] + + def _redirect(self, location: str) -> None: + self.send_response(HTTPStatus.SEE_OTHER) + self._security_headers() + self.send_header("Location", location) + self.end_headers() + + def _send_empty(self, status: HTTPStatus) -> None: + self.send_response(status) + self._security_headers() + self.send_header("Content-Length", "0") + self.end_headers() + + def _send_text( + self, + status: HTTPStatus, + message: str, + send_body: bool = True, + ) -> None: + body = message.encode("utf-8") + self.send_response(status) + self._security_headers() + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if send_body: + self.wfile.write(body) + + def _security_headers(self) -> None: + self.send_header("Cache-Control", "no-store") + self.send_header("Pragma", "no-cache") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header( + "Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; " + "form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ) + + def log_message(self, _format: str, *args: object) -> None: + # Do not log query strings, cookies, form bodies, or tokens. + LOG.info("%s %s", self.command, urlsplit(self.path).path) + + return PortalAuthHandler + + +def _form_value(form: dict[str, list[str]], name: str) -> str: + values = form.get(name) + return values[0] if values else "" + + +def main() -> None: + logging.basicConfig( + level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + config = AuthConfig.from_environment() + server = ThreadingHTTPServer( + (config.bind_address, config.port), build_handler(config) + ) + LOG.info("HMM portal authentication service listening on loopback port %s", config.port) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/poc4_active_source_20260714/src/agent_console/presentation.py index 9ed3d23..56fac2e 100644 --- a/poc4_active_source_20260714/src/agent_console/presentation.py +++ b/poc4_active_source_20260714/src/agent_console/presentation.py @@ -94,6 +94,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: .st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }} .console-login {{ text-align:left; }} .console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }} + a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem; + margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; + border:1px solid var(--console-border); border-radius:4px; + text-align:center; text-decoration:none; font-weight:700; }} + a.console-logout-button:hover {{ background:#f6f8fa; }} @media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }} """, diff --git a/poc4_active_source_20260714/tests/test_auth_gateway.py b/poc4_active_source_20260714/tests/test_auth_gateway.py new file mode 100644 index 0000000..689150a --- /dev/null +++ b/poc4_active_source_20260714/tests/test_auth_gateway.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import hashlib +import http.client +import os +from pathlib import Path +import sys +import threading +import unittest +from urllib.parse import urlencode + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from src.agent_console.auth_gateway import ( # noqa: E402 + AuthConfig, + LoginAttemptLimiter, + SESSION_COOKIE_NAME, + SessionTokenCodec, + build_handler, + clear_session_cookie_header, + password_matches, + session_cookie_header, +) + + +class AuthGatewayTest(unittest.TestCase): + def setUp(self) -> None: + self.secret = "s" * 48 + self.codec = SessionTokenCodec(self.secret) + + def test_session_token_round_trip_and_tamper_rejection(self) -> None: + token = self.codec.issue("demo-admin", 3600, now=1_000) + + session = self.codec.verify(token, "demo-admin", now=1_001) + + self.assertIsNotNone(session) + self.assertEqual("demo-admin", session.username) + self.assertEqual(4_600, session.expires_at) + self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001)) + self.assertIsNone(self.codec.verify(token, "other-user", now=1_001)) + + def test_expired_session_token_is_rejected(self) -> None: + token = self.codec.issue("demo-admin", 300, now=1_000) + + self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300)) + + def test_remember_cookie_has_required_security_attributes(self) -> None: + header = session_cookie_header("signed-value", 604_800) + + self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header) + self.assertIn("Path=/", header) + self.assertIn("Secure", header) + self.assertIn("HttpOnly", header) + self.assertIn("SameSite=Lax", header) + self.assertIn("Max-Age=604800", header) + self.assertNotIn("Domain=", header) + + def test_session_cookie_omits_persistent_max_age(self) -> None: + header = session_cookie_header("signed-value", None) + + self.assertNotIn("Max-Age", header) + self.assertIn("HttpOnly", header) + + def test_logout_cookie_expires_immediately(self) -> None: + header = clear_session_cookie_header() + + self.assertIn("Max-Age=0", header) + self.assertIn("Secure", header) + self.assertIn("HttpOnly", header) + + def test_pbkdf2_password_verification(self) -> None: + salt = bytes.fromhex("00112233445566778899aabbccddeeff") + expected = hashlib.pbkdf2_hmac( + "sha256", b"correct-password", salt, 200_000 + ).hex() + encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}" + + self.assertTrue(password_matches("correct-password", encoded)) + self.assertFalse(password_matches("wrong-password", encoded)) + + def test_rate_limiter_blocks_only_after_threshold(self) -> None: + limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10) + + limiter.record_failure("client", now=1) + self.assertFalse(limiter.blocked("client", now=2)) + limiter.record_failure("client", now=3) + self.assertTrue(limiter.blocked("client", now=4)) + self.assertFalse(limiter.blocked("client", now=20)) + + def test_environment_config_requires_new_cookie_secret(self) -> None: + previous = dict(os.environ) + try: + os.environ["POC4_LOGIN_USER"] = "demo-admin" + os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded" + os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None) + with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"): + AuthConfig.from_environment() + finally: + os.environ.clear() + os.environ.update(previous) + + def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None: + salt = bytes.fromhex("00112233445566778899aabbccddeeff") + expected = hashlib.pbkdf2_hmac( + "sha256", b"correct-password", salt, 200_000 + ).hex() + config = AuthConfig( + username="demo-admin", + password_pbkdf2=( + f"pbkdf2_sha256$200000${salt.hex()}${expected}" + ), + cookie_secret=self.secret, + port=8621, + ) + from http.server import ThreadingHTTPServer + + server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_address[1], timeout=3 + ) + try: + connection.request("GET", "/auth/login") + login_page = connection.getresponse() + body = login_page.read().decode("utf-8") + self.assertEqual(200, login_page.status) + csrf_header = next( + value + for name, value in login_page.getheaders() + if name.lower() == "set-cookie" + and value.startswith("__Host-HMM_LOGIN_CSRF=") + ) + csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0] + self.assertIn( + f'name="csrf" value="{csrf_value}"', + body, + ) + + payload = urlencode( + { + "csrf": csrf_value, + "username": "demo-admin", + "password": "correct-password", + "remember": "yes", + } + ) + connection.request( + "POST", + "/auth/login", + body=payload, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}", + }, + ) + logged_in = connection.getresponse() + logged_in.read() + self.assertEqual(303, logged_in.status) + self.assertEqual("/", logged_in.getheader("Location")) + self.assertNotRegex(logged_in.getheader("Location"), r"token|remember") + session_header = next( + value + for name, value in logged_in.getheaders() + if name.lower() == "set-cookie" + and value.startswith(f"{SESSION_COOKIE_NAME}=") + ) + session_value = session_header.split("=", 1)[1].split(";", 1)[0] + self.assertIn("Secure", session_header) + self.assertIn("HttpOnly", session_header) + self.assertIn("SameSite=Lax", session_header) + + connection.request( + "GET", + "/auth/check", + headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"}, + ) + check = connection.getresponse() + check.read() + self.assertEqual(204, check.status) + self.assertEqual("demo-admin", check.getheader("X-Auth-User")) + + connection.request( + "GET", + "/auth/check", + headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"}, + ) + tampered = connection.getresponse() + tampered.read() + self.assertEqual(401, tampered.status) + + connection.request( + "GET", + "/auth/logout", + headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"}, + ) + logout = connection.getresponse() + logout.read() + self.assertEqual(303, logout.status) + self.assertEqual("/auth/login", logout.getheader("Location")) + self.assertTrue( + any( + name.lower() == "set-cookie" and "Max-Age=0" in value + for name, value in logout.getheaders() + ) + ) + finally: + connection.close() + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +if __name__ == "__main__": + unittest.main() From 1917df09a2d014ce249b8a6af041775c4974d7fd Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 14:45:18 +0900 Subject: [PATCH 11/18] refs #712: authenticate backoffice MCP user tokens --- .../702-hmm-backoffice-identity/README.md | 2 +- .../712-hmm-backoffice-mcp-bearer/README.md | 78 ++++++++++ ...-hmm-backoffice-mcp-bearer-verification.md | 60 ++++++++ docs/runbooks/hmm-mcp-token-configuration.md | 140 ++++++++++++++++++ .../service/HmmAiAgentToolRunner.java | 2 +- .../service/HmmMcpBearerAuthenticator.java | 8 + .../service/HmmMcpPrincipal.java | 9 ++ .../service/JdbcHmmAiAgentToolRunner.java | 49 +++++- .../JdbcHmmMcpBearerAuthenticator.java | 45 ++++++ .../vpdbackoffice/service/McpSseService.java | 22 +-- .../service/McpUnauthorizedException.java | 9 ++ .../vpdbackoffice/web/McpSseController.java | 26 +++- src/main/resources/application.yml | 2 +- src/main/resources/templates/mcp-sse.html | 4 +- src/main/resources/templates/settings.html | 2 +- .../service/JdbcHmmAiAgentToolRunnerTest.java | 65 ++++++++ .../service/McpSseServiceTest.java | 35 ++++- .../web/SettingsTemplateRenderTest.java | 4 +- 18 files changed, 525 insertions(+), 37 deletions(-) create mode 100644 docs/design/712-hmm-backoffice-mcp-bearer/README.md create mode 100644 docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md create mode 100644 docs/runbooks/hmm-mcp-token-configuration.md create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index 2420d96..0623966 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -75,7 +75,7 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS ## HMM MCP 및 시스템 설정 - 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은 - `https://hmm-mcp.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로 + 사용자 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로 도메인 변경 시 화면 소스를 수정하지 않는다. - HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`, `search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`, diff --git a/docs/design/712-hmm-backoffice-mcp-bearer/README.md b/docs/design/712-hmm-backoffice-mcp-bearer/README.md new file mode 100644 index 0000000..1cf02a8 --- /dev/null +++ b/docs/design/712-hmm-backoffice-mcp-bearer/README.md @@ -0,0 +1,78 @@ +# HMM 백오피스 MCP 사용자 Bearer 인증 설계 (#712) + +> 상태: 구현·배포·검증 완료 +> 대상: `https://hmm-backoffice.cloud-handson.com/mcp` +> 브랜치: `hmm-backoffice` + +## 목적 + +HMM 백오피스에서 발급한 `vpd_live_*` 사용자 토큰을 MCP의 실제 인증 수단으로 사용한다. +현재 별도 호스트의 `https://hmm-mcp.cloud-handson.com/mcp`는 서버 공용 게이트웨이 토큰만 +허용하므로 사용자 토큰을 보내면 Nginx에서 HTTP 401을 반환한다. 사용자별 HMM 데모는 +백오피스 MCP 주소를 사용해야 한다. + +## 확인된 현행 결함 + +- 백오피스의 `/mcp`는 `initialize`, `tools/list`, `tools/call`을 제공한다. +- Controller가 `Authorization: Bearer` 값을 추출하지만 Service의 + `ignoredAuthorization` 인자로 전달해 검증하지 않는다. +- Tool 실행은 토큰 사용자 컨텍스트를 설정하지 않고 백오피스 JDBC 계정으로 바로 + `DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다. +- 따라서 주소를 올바르게 사용해도 토큰이 인증과 행 접근 문맥에 연결되지 않는다. + +## 변경 설계 + +```text +Private Agent Factory + → POST https://hmm-backoffice.cloud-handson.com/mcp + → Authorization: Bearer + → HMM_ACCESS_BEARER_TOKENS SHA-256/만료/회수/재직 검증 + → 동일 JDBC connection에서 HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER + → DBMS_CLOUD_AI_AGENT.RUN_TOOL + → finally HMM_ACCESS_CTX_PKG.CLEAR_USER +``` + +1. 모든 MCP method는 활성 사용자 Bearer를 요구한다. 누락·오류·만료·회수 토큰은 HTTP 401로 + fail-closed한다. +2. 원문 토큰은 로그, 응답, DB, Git에 기록하지 않는다. DB에는 기존 SHA-256 해시만 사용한다. +3. `tools/call`은 토큰 검증과 Tool 실행을 같은 요청에서 수행한다. +4. Tool 실행 connection에는 컨텍스트를 설정하고 성공·실패와 무관하게 `finally`에서 지운다. +5. `initialize`와 `tools/list`도 토큰을 검증해 discovery만으로 인증을 우회할 수 없게 한다. + +## Agent Factory 설정 + +| 항목 | 값 | +|---|---| +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | `Bearer Token` | +| Bearer token | 백오피스에서 발급한 토큰 원문만 입력 (`Bearer ` 접두어 제외) | + +별도 서버 `https://hmm-mcp.cloud-handson.com/mcp`에는 사용자 토큰을 사용하지 않는다. 그 주소는 +운영 `HMM_MCP_BEARER_TOKEN`을 사용하는 호환 게이트웨이다. + +## 완료 기준 + +- 활성 사용자 토큰으로 `initialize`, `tools/list`, 세 Tool 호출이 성공한다. +- 누락·무효·회수 토큰은 HTTP 401이다. +- Tool 호출 전후 DB context 설정·정리가 자동 테스트로 검증된다. +- 운영 배포 후 외부 HTTPS에서 discovery와 대표 Tool 호출을 검증한다. +- 토큰 원문이나 해시는 테스트 출력과 문서에 남지 않는다. + +## 배포 검증 결과 + +2026-07-23 운영 배포에서 다음을 확인했다. + +- 자동 테스트 107건 통과 +- 운영 JAR과 검증 빌드 SHA-256 일치 +- 무토큰 `initialize`, `tools/list`: HTTP 401 +- 활성 E1002 임시 사용자 토큰: + - `initialize`: HTTP 200 + - `tools/list`: HTTP 200 + - `resolve_hr_term`: HTTP 200, 정상 MCP result + - 발견 도구: `resolve_hr_term`, `search_hr_data`, `search_hr_policy` +- 같은 토큰을 회수한 직후 `tools/list`: HTTP 401 +- 검증 중 발급한 임시 토큰과 이전 실패 시 남은 임시 토큰을 모두 회수 +- 백오피스 시스템 설정과 MCP 서비스 화면의 Agent Factory 주소를 + `https://hmm-backoffice.cloud-handson.com/mcp`로 변경 + +상세 증거는 `docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md`에 기록한다. diff --git a/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md b/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md new file mode 100644 index 0000000..9499f25 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md @@ -0,0 +1,60 @@ +# HMM 백오피스 MCP 사용자 Bearer 검증 보고서 + +- 일자: 2026-07-23 +- Redmine: #712 +- 브랜치: `hmm-backoffice` +- 서비스: `https://hmm-backoffice.cloud-handson.com/mcp` + +## 원인 + +백오피스에서 발급한 `vpd_live_*` 직원 토큰을 별도 호환 서버인 +`https://hmm-mcp.cloud-handson.com/mcp`에 보냈다. 이 서버의 Nginx는 운영 공용 +`HMM_MCP_BEARER_TOKEN` 한 개만 비교하므로, 직원 토큰에서 `Bearer ` 문자열을 제거해도 HTTP 401이 +정상이다. + +직원 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`다. 조사 시 이 경로는 MCP 도구를 +제공했지만 Authorization 값을 실제로 검증하지 않고 버리는 결함도 확인됐다. + +## 수정 + +- 모든 MCP method에서 `HMM_ACCESS_BEARER_TOKENS`의 SHA-256 해시, 만료, 회수, 직원 재직 상태 검증 +- 누락·무효·만료·회수 토큰은 HTTP 401 +- Tool 호출 connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` 실행 +- 같은 connection에서 `DBMS_CLOUD_AI_AGENT.RUN_TOOL` 실행 +- 성공·실패와 무관하게 `finally`에서 `HMM_ACCESS_CTX_PKG.CLEAR_USER` +- Agent Factory 표시 주소를 백오피스 MCP 주소로 수정 +- 공용 gateway와 직원 VPD endpoint의 주소·토큰 조합을 운영 문서에서 분리 + +토큰 원문은 Controller에서 `Bearer ` 접두어만 제거해 전달하며 로그·응답·DB에 기록하지 않는다. +DB에는 기존 SHA-256 해시와 식별용 prefix만 남는다. + +## 검증 + +| 항목 | 결과 | +|---|---| +| Maven 자동 테스트 | 107건 통과 | +| 같은 JDBC connection의 context 설정·Tool 실행·context 정리 | 단위 테스트 통과 | +| 운영 JAR SHA-256 | 로컬 검증 빌드와 일치 | +| 서비스 | `vpd-backoffice.service` active | +| 로그인 상태 | HTTP 200 | +| 무토큰 MCP | HTTP 401 | +| 활성 E1002 임시 토큰 initialize | HTTP 200 | +| 활성 E1002 임시 토큰 tools/list | HTTP 200 | +| Tool discovery | HMM Tool 3개 | +| `resolve_hr_term` Tool 호출 | HTTP 200 / MCP result 성공 | +| 회수 후 같은 토큰 | HTTP 401 | + +검증용 토큰은 백오피스의 정상 발급 흐름으로 만들고 원문을 출력하지 않았다. 검증 완료 시 즉시 +회수했으며, 이전 실패 과정에서 남은 같은 용도의 임시 토큰 한 건도 함께 회수했다. + +## Agent Factory 최종 입력 + +| 항목 | 값 | +|---|---| +| Server name | `hmm-backoffice-mcp` | +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | `Bearer Token` | +| Bearer token | 백오피스 발급 토큰 원문만 입력 | + +Token 입력란에 `Bearer ` 접두어를 직접 쓰지 않는다. Agent Factory가 HTTP +`Authorization: Bearer ` 헤더를 조립한다. OAuth URL과 client ID/secret은 사용하지 않는다. diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md new file mode 100644 index 0000000..034da2f --- /dev/null +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -0,0 +1,140 @@ +# HMM MCP endpoint와 토큰 설정 + +## 인증 수단 구분 + +HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한다. + +| 인증 수단 | 사용 위치 | 전달 방식 | MCP 호출 사용 여부 | +|---|---|---|---| +| `__Host-HMM_PORTAL_SESSION` | `hmm.cloud-handson.com` 포털 로그인 | 브라우저 `Secure; HttpOnly` 쿠키 | 사용 금지 | +| `HMM_MCP_BEARER_TOKEN` | 별도 호환 MCP 서버 접근 | HTTP `Authorization: Bearer` | 포털의 공용 gateway에 사용 | +| HMM 직원별 VPD 토큰 | 백오피스 MCP 사용자 인증·DB context | HTTP `Authorization: Bearer` | Agent Factory 사용자별 연동에 사용 | + +포털 쿠키를 복사해 MCP Bearer Token으로 사용하면 안 된다. 브라우저 JavaScript에서도 포털 쿠키를 +읽을 수 없도록 `HttpOnly`로 설정한다. + +## MCP endpoint 구분 + +두 주소는 같은 토큰을 받지 않는다. + +| 항목 | 값 | +|---|---| +| 사용자별 VPD MCP | `https://hmm-backoffice.cloud-handson.com/mcp` | +| 사용자별 인증 | 백오피스에서 발급한 `vpd_live_*` 토큰 원문 | +| 공용 호환 MCP | `https://hmm-mcp.cloud-handson.com/mcp` | +| 공용 인증 | 운영 `HMM_MCP_BEARER_TOKEN` | +| Transport | Streamable HTTP POST | + +허용 도구는 다음 세 개다. + +| 도구 | 주요 인자 | 용도 | +|---|---|---| +| `search_hr_data` | `query` | 조직, 직원, 휴가 잔여·신청, 근태 조회 | +| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 | +| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 | + +## 포털의 현재 공용 토큰 조합 + +`config/vpd_token_presets.json`의 현재 조합은 다음과 같다. + +| 데모 사용자 | 역할 | `mcp_token_env` | +|---|---|---| +| E1001 Kim Minseo | HR Team Manager | `HMM_MCP_BEARER_TOKEN` | +| E1002 Lee Jiwon | HR Operations Specialist | `HMM_MCP_BEARER_TOKEN` | +| E1003 Park Dohyun | People Analytics Analyst | `HMM_MCP_BEARER_TOKEN` | +| E1005 Han Seojun | Recruiting Specialist | `HMM_MCP_BEARER_TOKEN` | +| E1007 Kang Minho | HR Coordinator | `HMM_MCP_BEARER_TOKEN` | + +현재는 모든 preset이 같은 서버 관리 토큰을 쓴다. preset을 바꾸면 질문에 포함되는 데모 사용자 +문맥은 바뀌지만, Bearer Token 자체는 바뀌지 않는다. 따라서 이 조합만으로는 사용자별 VPD +보안 경계를 만들지 못한다. + +## 포털 설정 + +`config/mcp_servers.json`에는 토큰 원문 대신 환경변수 이름만 기록한다. + +```json +{ + "id": "hmm_hr_mcp", + "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", + "auth_token_env": "HMM_MCP_BEARER_TOKEN", + "tool_allowlist": [ + "search_hr_data", + "resolve_hr_term", + "search_hr_policy" + ] +} +``` + +운영 서버 `/opt/hmm-poc4/.env`에 실제 값이 있어야 한다. + +```dotenv +HMM_MCP_BEARER_TOKEN= +POC3_MCP_TIMEOUT_SECONDS=45 +``` + +토큰 원문은 JSON, Git, 대화 기록, 화면 상세에 저장하지 않는다. 환경 파일은 운영 계정만 읽을 +수 있게 제한한다. + +## 사용자 VPD MCP 직접 호출 예시 + +```bash +export HMM_USER_BEARER_TOKEN='<백오피스 발급 화면에서 한 번 표시된 원문>' + +curl --fail-with-body \ + -H "Authorization: Bearer ${HMM_USER_BEARER_TOKEN}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + --data '{ + "jsonrpc": "2.0", + "id": "tools-list-1", + "method": "tools/list", + "params": {} + }' \ + https://hmm-backoffice.cloud-handson.com/mcp + +unset HMM_USER_BEARER_TOKEN +``` + +MCP 서버가 `initialize`와 session ID를 요구하면 다음 순서를 사용한다. + +1. `initialize` +2. 응답의 `Mcp-Session-Id` 보관 +3. `notifications/initialized` +4. 같은 session header와 Bearer Token으로 `tools/list` +5. 같은 session header와 Bearer Token으로 `tools/call` + +## Oracle AI Database Private Agent Factory 설정 + +| 입력 항목 | 값 | +|---|---| +| Server name | `hmm-backoffice-mcp` | +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | Bearer Token | +| Token | 백오피스에서 해당 직원에게 발급한 토큰 원문. `Bearer ` 문자열은 붙이지 않음 | +| Allowed tools | 위 세 도구만 선택 | +| Timeout | 45초부터 시작 | + +이 endpoint는 OAuth authorization endpoint가 아니다. OAuth client ID, client secret, +authorization URL, token URL은 입력하지 않는다. + +## 사용자별 VPD 적용 구조 + +MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token + 직원 VPD token” 두 개를 +같은 header에 조합하지 않는다. 다음 구조가 권장된다. + +1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다. +2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다. +3. 검증된 같은 token으로 DB 연결에서 + `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다. +4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. +5. `finally`에서 context를 초기화하고 connection pool에 반환한다. +6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 + application identity와 user subject를 하나의 검증 가능한 access token으로 합친다. + +이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직 +상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` +후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다. + +포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다. +Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java index 5acc154..81f6c80 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java @@ -7,5 +7,5 @@ import com.fasterxml.jackson.databind.node.ObjectNode; @FunctionalInterface public interface HmmAiAgentToolRunner { - JsonNode run(String toolName, ObjectNode input); + JsonNode run(String toolName, ObjectNode input, String bearerToken); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java new file mode 100644 index 0000000..5fc7139 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Validates an HMM backoffice bearer token without retaining its plaintext value. */ +@FunctionalInterface +public interface HmmMcpBearerAuthenticator { + + HmmMcpPrincipal authenticate(String bearerToken); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java new file mode 100644 index 0000000..75b9843 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Authenticated HMM employee identity resolved from a one-way bearer-token hash. */ +public record HmmMcpPrincipal( + long employeeId, + String employeeCode, + Long teamId +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java index 8a8264f..cb53f17 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java @@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.TextNode; +import java.sql.CallableStatement; import java.sql.Clob; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -20,16 +24,45 @@ public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner { } @Override - public JsonNode run(String toolName, com.fasterxml.jackson.databind.node.ObjectNode input) { + public JsonNode run( + String toolName, + com.fasterxml.jackson.databind.node.ObjectNode input, + String bearerToken + ) { try { String request = objectMapper.writeValueAsString(input); - String response = jdbcTemplate.queryForObject(""" - SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?)) - FROM dual - """, (resultSet, rowNum) -> { - Clob clob = resultSet.getClob(1); - return clob == null ? "" : clob.getSubString(1, (int) clob.length()); - }, toolName, request); + String response = jdbcTemplate.execute((ConnectionCallback) connection -> { + boolean contextSet = false; + try { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) { + statement.setString(1, bearerToken); + statement.execute(); + contextSet = true; + } + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?)) + FROM dual + """)) { + statement.setString(1, toolName); + statement.setString(2, request); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return ""; + } + Clob clob = resultSet.getClob(1); + return clob == null ? "" : clob.getSubString(1, (int) clob.length()); + } + } + } finally { + if (contextSet) { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + statement.execute(); + } + } + } + }); if (response == null || response.isBlank()) { return objectMapper.createObjectNode(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java new file mode 100644 index 0000000..a343091 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java @@ -0,0 +1,45 @@ +package com.cloudhandson.vpdbackoffice.service; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** Resolves active HMM employee tokens using only their SHA-256 hashes in ADB. */ +@Service +public class JdbcHmmMcpBearerAuthenticator implements HmmMcpBearerAuthenticator { + + private static final int MAX_TOKEN_LENGTH = 4096; + + private final JdbcTemplate jdbcTemplate; + + public JdbcHmmMcpBearerAuthenticator(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public HmmMcpPrincipal authenticate(String bearerToken) { + if (bearerToken == null || bearerToken.isBlank() || bearerToken.length() > MAX_TOKEN_LENGTH) { + throw new McpUnauthorizedException(); + } + try { + return jdbcTemplate.queryForObject(""" + SELECT employee.employee_id, + employee.employee_code, + employee.team_id + FROM hmm_access_bearer_tokens token + JOIN hmm_hr_employees employee + ON employee.employee_id = token.employee_id + WHERE token.key_hash = STANDARD_HASH(?, 'SHA256') + AND token.revoked_at IS NULL + AND token.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP) + AND employee.employment_status = 'ACTIVE' + """, (resultSet, rowNum) -> new HmmMcpPrincipal( + resultSet.getLong("employee_id"), + resultSet.getString("employee_code"), + resultSet.getObject("team_id", Long.class) + ), bearerToken); + } catch (EmptyResultDataAccessException exception) { + throw new McpUnauthorizedException(); + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 0645b4e..2330ce8 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -37,10 +37,16 @@ public class McpSseService { ); private final HmmAiAgentToolRunner agentToolRunner; + private final HmmMcpBearerAuthenticator bearerAuthenticator; private final ObjectMapper objectMapper; - public McpSseService(HmmAiAgentToolRunner agentToolRunner, ObjectMapper objectMapper) { + public McpSseService( + HmmAiAgentToolRunner agentToolRunner, + HmmMcpBearerAuthenticator bearerAuthenticator, + ObjectMapper objectMapper + ) { this.agentToolRunner = agentToolRunner; + this.bearerAuthenticator = bearerAuthenticator; this.objectMapper = objectMapper; } @@ -48,11 +54,9 @@ public class McpSseService { return handle(contextPath, request, ""); } - /** - * Backoffice authentication protects this compatibility endpoint. The public - * HMM MCP endpoint performs its own fixed Bearer-token validation in Nginx. - */ - public ObjectNode handle(String contextPath, JsonNode request, String ignoredAuthorization) { + /** Validates the user bearer before serving discovery or executing a tool. */ + public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) { + bearerAuthenticator.authenticate(bearerToken); ObjectNode response = objectMapper.createObjectNode(); response.put("jsonrpc", "2.0"); if (request != null && request.has("id")) { @@ -66,7 +70,7 @@ public class McpSseService { case "initialize" -> initializeResult(contextPath); case "notifications/initialized" -> objectMapper.createObjectNode(); case "tools/list" -> toolsListResult(); - case "tools/call" -> toolsCallResult(parameters); + case "tools/call" -> toolsCallResult(parameters, bearerToken); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); }); } catch (Exception exception) { @@ -128,7 +132,7 @@ public class McpSseService { return item; } - private ObjectNode toolsCallResult(JsonNode params) { + private ObjectNode toolsCallResult(JsonNode params, String bearerToken) { String requestedName = params.path("name").asText(""); ToolSpec tool = HMM_TOOLS.stream() .filter(candidate -> candidate.name().equals(requestedName)) @@ -140,7 +144,7 @@ public class McpSseService { } ObjectNode input = objectMapper.createObjectNode(); input.put(tool.agentParameterName(), argument); - JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input); + JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken); ObjectNode payload = objectMapper.createObjectNode(); payload.put("toolName", tool.name()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java new file mode 100644 index 0000000..f60b453 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Deliberately generic MCP authentication failure; never include token material. */ +public class McpUnauthorizedException extends RuntimeException { + + public McpUnauthorizedException() { + super("유효한 HMM 사용자 Bearer Token이 필요합니다."); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java index e78738d..6d9b078 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.McpSseService; +import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -40,12 +41,18 @@ public class McpSseController { @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @RequestBody JsonNode request ) { - // JSON-RPC notifications never receive a response body. Current MCP - // clients send notifications/initialized immediately after initialize. - if (request != null && !request.has("id")) { - return ResponseEntity.accepted().build(); + try { + ObjectNode response = mcpSseService.handle( + "default", request, bearerToken(authorization)); + // JSON-RPC notifications never receive a response body. Current MCP + // clients send notifications/initialized immediately after initialize. + if (request != null && !request.has("id")) { + return ResponseEntity.accepted().build(); + } + return ResponseEntity.ok(response); + } catch (McpUnauthorizedException exception) { + return ResponseEntity.status(401).build(); } - return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization))); } @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -98,8 +105,13 @@ public class McpSseController { JsonNode request ) throws IOException { String normalizedContextPath = normalizeContextPath(contextPath); - ObjectNode response = mcpSseService.handle( - normalizedContextPath, request, bearerToken(authorization)); + ObjectNode response; + try { + response = mcpSseService.handle( + normalizedContextPath, request, bearerToken(authorization)); + } catch (McpUnauthorizedException exception) { + return ResponseEntity.status(401).build(); + } if (sessionId == null || sessionId.isBlank()) { return ResponseEntity.ok(response); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eaec76e..5769bf1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,7 +62,7 @@ backoffice: username: ${BACKOFFICE_ORDS_DB_USERNAME:} password: ${BACKOFFICE_ORDS_DB_PASSWORD:} mcp: - public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-mcp.cloud-handson.com/mcp} + public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp} ai: enabled: ${BACKOFFICE_AI_ENABLED:false} provider: ${BACKOFFICE_AI_PROVIDER:openai} diff --git a/src/main/resources/templates/mcp-sse.html b/src/main/resources/templates/mcp-sse.html index ea4c659..a0bf0c7 100644 --- a/src/main/resources/templates/mcp-sse.html +++ b/src/main/resources/templates/mcp-sse.html @@ -22,7 +22,7 @@
공개 MCP Endpoint - https://hmm-mcp.cloud-handson.com/mcp + https://hmm-backoffice.cloud-handson.com/mcp Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.
@@ -47,7 +47,7 @@ HMM MCP - https://hmm-mcp.cloud-handson.com/mcp + https://hmm-backoffice.cloud-handson.com/mcp Transport diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 5fa9230..9cdd944 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -31,7 +31,7 @@ Public MCP endpoint -

Agent Factory 등록 주소: https://hmm-mcp.cloud-handson.com/mcp

+

Agent Factory 등록 주소: https://hmm-backoffice.cloud-handson.com/mcp

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java new file mode 100644 index 0000000..1975660 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java @@ -0,0 +1,65 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.sql.CallableStatement; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; + +class JdbcHmmAiAgentToolRunnerTest { + + @Test + void setsAndClearsHMMContextOnTheSameConnection() throws Exception { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + Connection connection = mock(Connection.class); + CallableStatement setContext = mock(CallableStatement.class); + CallableStatement clearContext = mock(CallableStatement.class); + PreparedStatement runTool = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + Clob clob = mock(Clob.class); + + when(jdbcTemplate.execute(any(ConnectionCallback.class))).thenAnswer(invocation -> { + ConnectionCallback callback = invocation.getArgument(0); + return callback.doInConnection(connection); + }); + when(connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) + .thenReturn(setContext); + when(connection.prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) + .thenReturn(clearContext); + when(connection.prepareStatement(any(String.class))).thenReturn(runTool); + when(runTool.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + when(resultSet.getClob(1)).thenReturn(clob); + when(clob.length()).thenReturn(15L); + when(clob.getSubString(1, 15)).thenReturn("{\"status\":\"ok\"}"); + + var runner = new JdbcHmmAiAgentToolRunner(jdbcTemplate, new ObjectMapper()); + var result = runner.run( + "HMM_HR_TERM_RESOLVER", + new ObjectMapper().createObjectNode().put("P_TERM", "annual leave"), + "opaque-user-token"); + + assertThat(result.path("status").asText()).isEqualTo("ok"); + InOrder order = inOrder(connection, setContext, runTool, clearContext); + order.verify(connection).prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;"); + order.verify(setContext).setString(1, "opaque-user-token"); + order.verify(setContext).execute(); + order.verify(connection).prepareStatement(any(String.class)); + order.verify(runTool).executeQuery(); + order.verify(connection).prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;"); + order.verify(clearContext).execute(); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index bb7bc1d..328c347 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,11 +12,14 @@ class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner(); - private final McpSseService service = new McpSseService(agentToolRunner, objectMapper); + private final HmmMcpBearerAuthenticator bearerAuthenticator = + token -> new HmmMcpPrincipal(1L, "E1001", 1L); + private final McpSseService service = + new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper); @Test void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() { - ObjectNode response = service.handle("default", request(1, "tools/list")); + ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token"); var tools = response.path("result").path("tools"); assertThat(tools).hasSize(3); @@ -39,10 +43,11 @@ class McpSseServiceTest { params.put("name", "resolve_hr_term"); params.putObject("arguments").put("term", "연차 이월"); - ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session"); + ObjectNode response = service.handle("default", request, "valid-token"); assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER"); assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월"); + assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); assertThat(response.path("result").path("content").get(0).path("text").asText()) @@ -51,6 +56,20 @@ class McpSseServiceTest { .contains("ANNUAL_LEAVE_CARRYOVER"); } + @Test + void rejectsDiscoveryWhenBearerAuthenticationFails() { + McpSseService rejectingService = new McpSseService( + agentToolRunner, + token -> { + throw new McpUnauthorizedException(); + }, + objectMapper); + + assertThatThrownBy(() -> + rejectingService.handle("default", request(4, "tools/list"), "invalid-token")) + .isInstanceOf(McpUnauthorizedException.class); + } + @Test void rejectsUnknownToolsWithoutCallingTheAgentRunner() { ObjectNode request = request(3, "tools/call"); @@ -58,7 +77,7 @@ class McpSseServiceTest { params.put("name", "ords.query.kb_select_ai_vpd"); params.putObject("arguments").put("prompt", "legacy query"); - ObjectNode response = service.handle("default", request); + ObjectNode response = service.handle("default", request, "valid-token"); assertThat(response.path("result").isMissingNode()).isTrue(); assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool"); @@ -76,11 +95,17 @@ class McpSseServiceTest { private String toolName; private ObjectNode input; + private String bearerToken; @Override - public JsonNode run(String requestedToolName, ObjectNode requestedInput) { + public JsonNode run( + String requestedToolName, + ObjectNode requestedInput, + String bearerToken + ) { toolName = requestedToolName; input = requestedInput.deepCopy(); + this.bearerToken = bearerToken; return objectMapper.createObjectNode() .put("termCode", "ANNUAL_LEAVE_CARRYOVER") .put("termName", "연차 이월"); diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java index 6eac9da..62fde34 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java @@ -24,14 +24,14 @@ class SettingsTemplateRenderTest { var context = new Context(Locale.KOREAN); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("ordsBaseUrl", "https://ords.example.test/ords"); - context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp"); + context.setVariable("hmmMcpPublicUrl", "https://hmm-backoffice.cloud-handson.com/mcp"); String connection = engine.process("settings", context); String database = engine.process("settings-database", context); assertThat(connection) .contains("HMM HR Agent 도구") - .contains("https://hmm-mcp.cloud-handson.com/mcp") + .contains("https://hmm-backoffice.cloud-handson.com/mcp") .contains("Legacy ORDS Base URL") .contains("/settings/database") .doesNotContain("g329127dfd380ad-kbaipoc") From 0d9028ef13db3363a66d3ad7328e9803335d54ea Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 19:41:54 +0900 Subject: [PATCH 12/18] refs #723: externalize backoffice catalogs and MCP tools --- .env.example | 29 +- deploy/hmm-backoffice.env.example | 18 ++ .../vpd-backoffice-structured-data.conf | 2 +- .../723-hmm-configurable-backoffice/README.md | 255 ++++++++++++++++++ pom.xml | 10 +- .../vpdbackoffice/config/AppConfig.java | 9 +- .../config/BackofficeProperties.java | 27 +- .../config/CatalogProperties.java | 8 + .../config/MaskingProperties.java | 8 + .../vpdbackoffice/config/McpProperties.java | 54 ++++ .../config/ProductProperties.java | 20 ++ .../config/SecuritySqlScriptProperties.java | 8 + .../domain/masking/ManagedMaskingPolicy.java | 7 - .../structured/StructuredDataCatalog.java | 13 - .../structured/StructuredDataTable.java | 10 +- .../mapper/MaskingRuleMapper.java | 4 +- .../vpdbackoffice/service/DataCatalog.java | 13 + .../service/EnvironmentDataCatalog.java | 122 +++++++++ .../EnvironmentMaskingPolicyCatalog.java | 75 ++++++ .../service/EnvironmentMcpToolCatalog.java | 136 ++++++++++ .../service/MaskingPolicyCatalog.java | 22 ++ .../service/MaskingPolicySynchronizer.java | 63 ++--- .../service/MaskingPolicyTarget.java | 5 + .../service/MaskingRuleService.java | 9 +- .../vpdbackoffice/service/McpSseService.java | 118 ++++---- .../vpdbackoffice/service/McpToolCatalog.java | 10 + .../service/McpToolDefinition.java | 22 ++ .../service/SchemaMetadataService.java | 14 +- .../service/SecuritySqlScriptService.java | 100 ++++--- .../service/SelectAiService.java | 232 ++++++++++++++++ .../StructuredDataCatalogProvider.java | 115 -------- .../service/StructuredDataService.java | 47 ++-- .../web/DashboardController.java | 2 +- .../web/McpReasoningController.java | 10 +- .../vpdbackoffice/web/ProductModelAdvice.java | 41 +++ .../vpdbackoffice/web/SettingController.java | 10 +- src/main/resources/application.yml | 31 ++- .../config/structured-data-catalog.json | 59 ---- .../resources/templates/fragments/layout.html | 4 +- src/main/resources/templates/login.html | 2 +- .../resources/templates/masking-rules.html | 14 +- src/main/resources/templates/mcp-sse.html | 38 ++- .../resources/templates/operation-status.html | 4 +- src/main/resources/templates/roles.html | 2 +- .../resources/templates/schema-metadata.html | 20 +- src/main/resources/templates/settings.html | 10 +- .../resources/templates/structured-data.html | 14 +- .../templates/user-masking-rules.html | 8 +- src/main/resources/templates/users.html | 2 +- .../templates/vpd-filter-runtime.html | 4 +- .../MaskingPolicySynchronizerTest.java | 24 +- .../service/McpSseServiceTest.java | 117 ++++++-- .../service/SchemaMetadataServiceTest.java | 34 +++ .../service/SecuritySqlScriptServiceTest.java | 44 ++- .../service/SelectAiServiceTest.java | 35 +++ .../service/StructuredDataServiceTest.java | 69 +++-- .../web/GuidedFlowTemplateTest.java | 17 +- .../web/SettingsTemplateRenderTest.java | 2 +- 58 files changed, 1671 insertions(+), 531 deletions(-) create mode 100644 deploy/hmm-backoffice.env.example create mode 100644 docs/design/723-hmm-configurable-backoffice/README.md create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java delete mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java delete mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java delete mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java delete mode 100644 src/main/resources/config/structured-data-catalog.json create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java diff --git a/.env.example b/.env.example index f108310..03ac372 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,34 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_PASSWORD="" -# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- +# Select AI 프로파일 소유 스키마의 별도 연결. MCP 도구가 AGENT_TOOL만 사용하면 비워 둘 수 있습니다. +export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" +export BACKOFFICE_SELECT_AI_DB_USERNAME="" +export BACKOFFICE_SELECT_AI_DB_PASSWORD="" +export BACKOFFICE_SELECT_AI_PROFILE="" + +# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 --- +# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다. +export BACKOFFICE_CATALOG_OWNER="APP_OWNER" +export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]' +export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice" +export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice" +export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터" + +# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다. +export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp" +export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice" +export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql" +export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL" +export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다." +export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다." +export BACKOFFICE_MCP_TOOLS='' + +# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다. +export BACKOFFICE_MASKING_POLICIES='' +export BACKOFFICE_SECURITY_SQL_SCRIPTS='' + +# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- export BACKOFFICE_AI_ENABLED="false" export BACKOFFICE_AI_PROVIDER="openai" # openai | oci export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com diff --git a/deploy/hmm-backoffice.env.example b/deploy/hmm-backoffice.env.example new file mode 100644 index 0000000..76848fb --- /dev/null +++ b/deploy/hmm-backoffice.env.example @@ -0,0 +1,18 @@ +# HMM deployment profile for /etc/vpd-backoffice.env. +# Append these non-secret values to the existing file. Keep DB passwords, +# remember-me keys and user Bearer tokens outside Git. + +BACKOFFICE_CATALOG_OWNER=ADMIN +BACKOFFICE_CATALOG_OBJECTS='[{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE","businessName":"조직 원장","description":"HMM HR 조직·팀 기본정보"},{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE","businessName":"직원 원장","description":"직원·매니저·소속팀 정보"},{"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"TABLE","businessName":"휴가 잔여 원장","description":"직원별 연도·휴가 유형별 부여·사용·잔여 일수"},{"key":"leave-requests","tableName":"HMM_LEAVE_REQUESTS","objectType":"TABLE","businessName":"휴가 신청 원장","description":"직원별 휴가 신청·승인 상태와 기간"},{"key":"attendance-daily","tableName":"HMM_ATTENDANCE_DAILY","objectType":"TABLE","businessName":"일별 근태 원장","description":"직원별 출퇴근·근무 상태와 근무 시간"},{"key":"hr-terms","tableName":"HMM_HR_TERMS","objectType":"TABLE","businessName":"HR 표준 용어 원장","description":"휴가·근태 표준 코드, 명칭과 유사 표현","previewColumns":["TERM_ID","TERM_CODE","TERM_KIND","CANONICAL_NAME","TERM_NAME","IS_CANONICAL","DESCRIPTION","EMBEDDED_AT"]}]' + +BACKOFFICE_PRODUCT_NAME='HMM HR Access Console' +BACKOFFICE_PRODUCT_TITLE='HMM HR Access Console' +BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터' + +BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp' +BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice' +BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]' + +BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]' + +BACKOFFICE_SECURITY_SQL_SCRIPTS='[{"scriptId":"hmm-leave-vpd","category":"HMM / VPD","fileName":"72_hmm_leave_team_vpd.sql","title":"HMM 휴가 팀 접근 정책","description":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]' diff --git a/deploy/systemd/vpd-backoffice-structured-data.conf b/deploy/systemd/vpd-backoffice-structured-data.conf index 6bfff19..9d2655f 100644 --- a/deploy/systemd/vpd-backoffice-structured-data.conf +++ b/deploy/systemd/vpd-backoffice-structured-data.conf @@ -1,2 +1,2 @@ [Service] -Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json +EnvironmentFile=/etc/vpd-backoffice.env diff --git a/docs/design/723-hmm-configurable-backoffice/README.md b/docs/design/723-hmm-configurable-backoffice/README.md new file mode 100644 index 0000000..4f061b0 --- /dev/null +++ b/docs/design/723-hmm-configurable-backoffice/README.md @@ -0,0 +1,255 @@ +# HMM 백오피스 환경 기반 공통 카탈로그 전환 + +- Redmine: #723 +- 기준 설계: Smilegate #722 `docs/design/722-configurable-data-catalog/README.md` +- 기준 커밋: `53342e7` (`smilegate`) +- 적용 브랜치: `hmm-backoffice` +- 운영 주소: `https://hmm-backoffice.cloud-handson.com` + +## 1. 배경 + +현재 HMM 백오피스는 정형 데이터 목록을 별도 JSON 파일로 분리했지만 제품명, MCP 도구, +마스킹 대상, 보안 SQL 목록 등은 Java와 Thymeleaf에 남아 있다. 같은 백오피스 틀을 다른 +고객사에 재사용하려면 소스를 수정하고 다시 빌드해야 한다. + +Smilegate #722는 데이터 객체, 제품 표시명, 마스킹 정책, MCP/Select AI, 보안 SQL 목록을 +환경 설정으로 옮겼다. HMM에는 이 구조를 적용하되 다음 현행 기능을 보존해야 한다. + +- HMM 직원 Bearer token을 해시로 검증한다. +- MCP discovery와 tool call 모두 인증한다. +- AI Agent Tool 실행과 같은 DB 세션에서 `HMM_ACCESS_CTX`를 설정하고 반드시 해제한다. +- 용어 변환, 정형 HR 조회, 규정 PDF 검색의 세 MCP 도구를 계속 제공한다. +- 팀장과 팀원의 VPD 행 접근 규칙 및 마스킹 관리 기능을 유지한다. + +## 2. 목표 + +1. 승인 데이터 객체와 객체 유형을 환경 JSON으로 설정한다. +2. TABLE과 VIEW를 모두 읽기 전용 미리보기 대상으로 지원한다. +3. DB comment는 TABLE/VIEW에 지원하고 Oracle annotation 변경은 TABLE에만 허용한다. +4. Data Redaction 관리 대상과 정책명을 별도 환경 JSON으로 설정한다. +5. 제품명, 페이지 타이틀, 데이터 명칭, MCP 공개 주소와 도구 계약을 환경 설정으로 옮긴다. +6. Smilegate #722의 단일 Select AI 도구 설정과 HMM의 복수 Agent Tool 설정을 모두 수용한다. +7. 번들 보안 SQL 화면의 노출 목록을 환경 allowlist로 제한한다. +8. 잘못된 설정은 애플리케이션 시작 시 거부한다. + +## 3. 제외 범위 + +- HMM 사용자·그룹·역할·권한 테이블 구조 변경 +- HMM 지식 문서 적재 구조 변경 +- 기존 VPD 함수와 Data Redaction 정책 DDL 재작성 +- Select AI 프로파일의 object list 자동 변경 +- 운영 Bearer token 원문을 설정이나 Git에 저장 + +## 4. 설정 계약 + +### 4.1 데이터 카탈로그 + +```text +BACKOFFICE_CATALOG_OWNER +BACKOFFICE_CATALOG_OBJECTS +``` + +`BACKOFFICE_CATALOG_OBJECTS`는 아래 필드를 갖는 JSON 배열이다. + +```json +[ + { + "key": "employees", + "tableName": "HMM_HR_EMPLOYEES", + "objectType": "TABLE", + "businessName": "직원 원장", + "description": "직원·매니저·소속팀 정보", + "previewColumns": ["EMPLOYEE_ID", "EMPLOYEE_CODE", "EMPLOYEE_NAME"] + } +] +``` + +검증 규칙: + +- owner와 tableName은 Oracle simple identifier만 허용한다. +- key는 소문자 영문으로 시작하고 소문자, 숫자, 하이픈만 허용한다. +- objectType은 `TABLE` 또는 `VIEW`만 허용한다. +- key와 tableName은 각각 중복될 수 없다. +- businessName과 description은 비어 있을 수 없다. +- previewColumns는 선택값이다. 지정하면 검증된 컬럼만 조회하며 VECTOR/BLOB 등 관리자 + 미리보기에 부적합한 컬럼을 제외할 수 있다. +- 빈 목록이나 잘못된 JSON이면 애플리케이션 시작을 실패시킨다. + +정형 데이터 미리보기는 카탈로그에서 선택한 객체만 SQL 식별자로 사용하고 최대 50건만 +반환한다. 요청 파라미터를 SQL 객체명으로 직접 사용하지 않는다. + +### 4.2 제품 표시 + +```text +BACKOFFICE_PRODUCT_NAME +BACKOFFICE_PRODUCT_TITLE +BACKOFFICE_PRODUCT_DATA_LABEL +``` + +공통 레이아웃, 데이터 화면, 메타데이터 화면, MCP 화면은 이 값을 사용한다. HMM 운영값은 +`HMM HR Access Console`과 `HMM HR 데이터`이다. + +### 4.3 마스킹 정책 + +```text +BACKOFFICE_MASKING_POLICIES +``` + +```json +[ + { + "objectName": "HMM_HR_EMPLOYEES", + "policyName": "HMM_EMPLOYEE_PII_REDACT" + } +] +``` + +객체명과 정책명은 Oracle simple identifier로 검증하고, objectName과 policyName 중복을 +각각 거부한다. 빈 값이면 관리 가능한 Data Redaction 정책이 없는 fail-closed 상태로 +동작한다. + +### 4.4 MCP + +Smilegate #722의 단일 Select AI 도구 환경변수를 호환한다. + +```text +BACKOFFICE_MCP_PUBLIC_URL +BACKOFFICE_MCP_SERVER_NAME +BACKOFFICE_MCP_TOOL_NAME +BACKOFFICE_MCP_TOOL_LABEL +BACKOFFICE_MCP_TOOL_DESCRIPTION +BACKOFFICE_MCP_PROMPT_DESCRIPTION +``` + +HMM처럼 여러 DBMS Cloud AI Agent Tool을 노출하는 배포는 다음 JSON을 사용한다. + +```text +BACKOFFICE_MCP_TOOLS +``` + +```json +[ + { + "name": "resolve_hr_term", + "label": "HMM HR 용어 표준화", + "description": "휴가·근태 표현을 표준 용어와 코드로 변환합니다.", + "argumentName": "term", + "argumentDescription": "확인할 휴가·근태 용어, 동의어 또는 코드입니다.", + "executionType": "AGENT_TOOL", + "targetName": "HMM_HR_TERM_RESOLVER", + "targetParameterName": "P_TERM" + } +] +``` + +`executionType`은 `AGENT_TOOL` 또는 `SELECT_AI`만 허용한다. `BACKOFFICE_MCP_TOOLS`가 +비어 있으면 #722의 단일 `SELECT_AI` 도구 설정을 사용한다. HMM 운영은 세 개의 +`AGENT_TOOL` 정의를 환경에 둔다. + +MCP 보안 경계: + +1. initialize, tools/list, tools/call 모두 유효한 HMM 사용자 Bearer token이 필요하다. +2. token 원문은 로그, 응답, DB에 저장하지 않는다. +3. AGENT_TOOL 실행 전 같은 JDBC 연결에서 + `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 실행한다. +4. 성공·실패와 관계없이 같은 연결에서 `CLEAR_USER`를 실행한다. +5. MCP JSON-RPC 오류에 DB password, SQL 전체 stack trace를 포함하지 않는다. + +### 4.5 Select AI + +```text +BACKOFFICE_SELECT_AI_DB_URL +BACKOFFICE_SELECT_AI_DB_USERNAME +BACKOFFICE_SELECT_AI_DB_PASSWORD +BACKOFFICE_SELECT_AI_PROFILE +``` + +단일 Select AI 도구는 프로파일 소유 계정으로 SHOWSQL을 생성한 후 다음 검증을 모두 +통과한 SQL만 읽기 전용 트랜잭션에서 실행한다. + +- 첫 문장은 SELECT 또는 WITH +- 세미콜론을 이용한 다중 문장 금지 +- DDL, DML, transaction, lock, PL/SQL, DBMS/UTL/SYS 호출 금지 +- 최대 100건, query timeout 30초 +- 실행 후 항상 rollback + +HMM 운영 MCP는 현재 세 개의 Agent Tool을 사용하므로 Select AI 접속은 선택 설정이다. +다른 고객사는 단일 Select AI 도구만 설정할 수 있다. + +### 4.6 보안 SQL + +```text +BACKOFFICE_SECURITY_SQL_SCRIPTS +``` + +번들된 `sql/adb` 경로 안의 파일만 허용하며 요청값을 resource path로 사용하지 않는다. +HMM 운영에서는 `72_hmm_leave_team_vpd.sql` 등 HMM 관련 SQL만 노출한다. + +## 5. 코드 구조 + +| 구성 | 역할 | +|---|---| +| `DataCatalog` | 승인 객체 조회 인터페이스 | +| `EnvironmentDataCatalog` | 환경 JSON 파싱, 정규화, fail-fast 검증 | +| `MaskingPolicyCatalog` | 관리 가능한 Data Redaction 대상 조회 | +| `EnvironmentMaskingPolicyCatalog` | 마스킹 환경 JSON 파싱과 검증 | +| `ProductProperties` | 제품명, 타이틀, 데이터 표시명 | +| `McpProperties` | 공개 URL, 서버명, 단일 Select AI 호환 설정, 복수 도구 JSON | +| `EnvironmentMcpToolCatalog` | MCP 도구 계약 파싱과 allowlist | +| `McpSseService` | JSON-RPC와 인증 경계, 설정 기반 도구 dispatch | +| `SelectAiService` | SHOWSQL 생성, 읽기 전용 검증 및 제한 실행 | +| `SecuritySqlScriptProperties` | 보안 SQL allowlist | + +고객사별 이름과 객체는 `application.yml`의 환경 매핑과 운영 env에만 둔다. HMM의 DB +매퍼와 VPD 함수는 HMM 업무 모델 자체이므로 이번 공통화 대상이 아니다. + +## 6. TABLE/VIEW 메타데이터 + +- `all_tab_comments`, `all_tab_columns`, `all_col_comments`는 TABLE/VIEW 공통 조회에 쓴다. +- `COMMENT ON TABLE`과 `COMMENT ON COLUMN`은 승인 객체에만 실행한다. +- Oracle annotation 조회·변경은 TABLE에만 허용한다. +- VIEW를 선택한 경우 annotation 입력 UI를 숨기고 서버도 요청을 거부한다. +- 화면에는 카탈로그의 objectType과 owner를 표시한다. + +## 7. 운영 이행 + +1. 기존 `/etc/vpd-backoffice.env`를 백업한다. +2. 기존 DB 비밀번호와 remember-me secret은 변경하지 않는다. +3. 데이터 카탈로그 6개, 마스킹 정책 4개, MCP 도구 3개, HMM 보안 SQL 목록을 추가한다. +4. `BACKOFFICE_HMM_MCP_PUBLIC_URL`은 + `BACKOFFICE_MCP_PUBLIC_URL`로 이전하되 한 릴리스 동안 fallback을 지원한다. +5. JAR 교체 후 systemd를 재시작한다. +6. 10초 간격으로 health, 로그인, 정형 데이터, 메타데이터, 마스킹, MCP를 확인한다. +7. 실패 시 기존 JAR와 env 백업으로 복구한다. + +## 8. 검증 + +자동 검증: + +- 카탈로그 정상/빈 값/잘못된 식별자/중복 key/중복 object 테스트 +- TABLE/VIEW 미리보기 SQL과 row limit 테스트 +- VIEW annotation 변경 거부 테스트 +- 마스킹 정책 정상/빈 값/중복 테스트 +- MCP 3개 도구 discovery, 정확한 argument/target dispatch, 인증 거부 테스트 +- Select AI 안전 SQL 검증 테스트 +- 보안 SQL resource allowlist 테스트 +- 전체 `mvn test` + +운영 검증: + +- 관리자 로그인과 지속 로그인 +- 전체 메뉴 HTTP 200 및 HMM 데이터 표시 +- 정형 데이터 6개 객체 조회 +- 메타데이터 6개 객체 조회 +- 마스킹 정책 4개 상태 조회 +- MCP 무토큰 401, 잘못된 token 401 +- 임시 사용자 token으로 tools/list와 세 도구 call +- 서비스 로그에 startup 오류와 비밀정보 출력이 없는지 확인 + +## 9. 완료 조건 + +- HMM 백오피스의 고객사별 카탈로그와 표시 설정이 운영 env로 이전되어 있다. +- 기존 HMM 토큰 인증과 VPD 컨텍스트 적용이 회귀하지 않는다. +- Maven 테스트와 운영 전수 검증이 통과한다. +- Gitea `hmm-backoffice`에 #723 커밋이 push되어 있다. +- Redmine #723이 Planner부터 Documenter까지 근거와 함께 완료되어 있다. diff --git a/pom.xml b/pom.xml index 8fc9b81..eefd731 100644 --- a/pom.xml +++ b/pom.xml @@ -92,17 +92,13 @@ src/main/resources - + sql/adb sql/adb - 62_kb_aso_masking_backoffice_metadata.sql - 63_kb_aso_masking_rule_runtime.sql - 64_kb_aso_masking_default_column_rules.sql - 65_kb_select_ai_vpd_query_api.sql - 66_kb_select_ai_vpd_query_ords.sql + **/*.sql diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java index 539c791..ab9520a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java @@ -6,7 +6,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties(BackofficeProperties.class) +@EnableConfigurationProperties({ + BackofficeProperties.class, + CatalogProperties.class, + MaskingProperties.class, + McpProperties.class, + ProductProperties.class, + SecuritySqlScriptProperties.class +}) public class AppConfig { @Bean diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 6d97e11..c980efc 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -8,8 +8,8 @@ public record BackofficeProperties( Security security, Token token, Ords ords, - Mcp mcp, - Ai ai + Ai ai, + SelectAi selectAi ) { public record Security( @@ -55,13 +55,6 @@ public record BackofficeProperties( public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) { } - /** - * Public HMM MCP endpoint used by Agent Factory and by operators who need to inspect the - * deployed tool contract. It is deliberately separate from the optional legacy ORDS URL. - */ - public record Mcp(String publicUrl) { - } - public record Ai( boolean enabled, String provider, @@ -80,4 +73,20 @@ public record BackofficeProperties( this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", ""); } } + + /** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */ + public record SelectAi( + String dbUrl, + String dbUsername, + String dbPassword, + String profile + ) { + + public boolean configured() { + return dbUrl != null && !dbUrl.isBlank() + && dbUsername != null && !dbUsername.isBlank() + && dbPassword != null && !dbPassword.isBlank() + && profile != null && !profile.isBlank(); + } + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java new file mode 100644 index 0000000..38f20df --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Deployment-provided allow-list for the structured-data and metadata screens. */ +@ConfigurationProperties(prefix = "backoffice.catalog") +public record CatalogProperties(String owner, String objects) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java new file mode 100644 index 0000000..ed31458 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** JSON configuration of database redaction policies this backoffice may manage. */ +@ConfigurationProperties(prefix = "backoffice.masking") +public record MaskingProperties(String policies) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java new file mode 100644 index 0000000..acf1c19 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -0,0 +1,54 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Product-neutral MCP endpoint labels and tool catalogue configuration. */ +@ConfigurationProperties(prefix = "backoffice.mcp") +public record McpProperties( + String publicUrl, + String serverName, + String toolName, + String toolLabel, + String toolDescription, + String promptDescription, + String tools +) { + + private static final String DEFAULT_PUBLIC_URL = "/mcp"; + private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice"; + private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; + private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL"; + private static final String DEFAULT_TOOL_DESCRIPTION = + "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 " + + "트랜잭션에서 실행합니다."; + private static final String DEFAULT_PROMPT_DESCRIPTION = + "업무 데이터에서 조회할 내용을 자연어로 입력합니다."; + + public String resolvedPublicUrl() { + return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL); + } + + public String resolvedServerName() { + return requiredOrDefault(serverName, DEFAULT_SERVER_NAME); + } + + public String resolvedToolName() { + return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); + } + + public String resolvedToolLabel() { + return requiredOrDefault(toolLabel, DEFAULT_TOOL_LABEL); + } + + public String resolvedToolDescription() { + return requiredOrDefault(toolDescription, DEFAULT_TOOL_DESCRIPTION); + } + + public String resolvedPromptDescription() { + return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION); + } + + private String requiredOrDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java new file mode 100644 index 0000000..a311670 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java @@ -0,0 +1,20 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Customer-facing labels that do not affect authorization or database identity. */ +@ConfigurationProperties(prefix = "backoffice.product") +public record ProductProperties(String name, String title, String dataLabel) { + + public String displayName() { + return name == null || name.isBlank() ? "Data & AI Backoffice" : name.trim(); + } + + public String pageTitle() { + return title == null || title.isBlank() ? displayName() : title.trim(); + } + + public String dataName() { + return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java new file mode 100644 index 0000000..69201f3 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Deployment-provided allow-list for bundled security SQL shown by the backoffice. */ +@ConfigurationProperties(prefix = "backoffice.security-sql-scripts") +public record SecuritySqlScriptProperties(String scripts) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java deleted file mode 100644 index 6b1959a..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cloudhandson.vpdbackoffice.domain.masking; - -public record ManagedMaskingPolicy( - String objectName, - String policyName -) { -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java deleted file mode 100644 index 32bb946..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cloudhandson.vpdbackoffice.domain.structured; - -import java.util.List; - -public record StructuredDataCatalog( - String sourceName, - String owner, - String pageHelp, - String catalogDescription, - int rowLimit, - List tables -) { -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index a8f6e0a..446c88a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -5,13 +5,17 @@ import java.util.List; public record StructuredDataTable( String key, String tableName, + String objectType, String businessName, String description, - List previewColumns, - String maskingPolicyName + List previewColumns ) { public StructuredDataTable(String key, String tableName, String businessName, String description) { - this(key, tableName, businessName, description, List.of(), null); + this(key, tableName, "TABLE", businessName, description, List.of()); + } + + public boolean isTable() { + return "TABLE".equals(objectType); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index eece38a..7c908ee 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -4,8 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; -import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; +import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -31,7 +31,7 @@ public interface MaskingRuleMapper { List findPolicyStatuses( @Param("owner") String owner, - @Param("policies") List policies + @Param("policies") List policies ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java new file mode 100644 index 0000000..b63fede --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java @@ -0,0 +1,13 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import java.util.List; + +public interface DataCatalog { + + String owner(); + + List objects(); + + StructuredDataTable require(String key); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java new file mode 100644 index 0000000..0f07ffa --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java @@ -0,0 +1,122 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.CatalogProperties; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Validated deployment allow-list for data preview and metadata operations. */ +@Service +public class EnvironmentDataCatalog implements DataCatalog { + + private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + private static final Pattern KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + + private final String owner; + private final List objects; + + public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper objectMapper) { + try { + owner = requireName(properties.owner(), "BACKOFFICE_CATALOG_OWNER"); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.", exception); + } + objects = parse(properties.objects(), objectMapper); + } + + @Override + public String owner() { + return owner; + } + + @Override + public List objects() { + return objects; + } + + @Override + public StructuredDataTable require(String key) { + return objects.stream() + .filter(item -> item.key().equals(key)) + .findFirst() + .orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다.")); + } + + private List parse(String raw, ObjectMapper objectMapper) { + if (raw == null || raw.isBlank()) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요."); + } + try { + List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("카탈로그 객체가 비어 있습니다."); + } + Set keys = new HashSet<>(); + Set objectNames = new HashSet<>(); + List normalized = parsed.stream() + .map(this::normalize) + .peek(item -> { + if (!keys.add(item.key())) { + throw new IllegalArgumentException("중복 key: " + item.key()); + } + if (!objectNames.add(item.tableName())) { + throw new IllegalArgumentException("중복 tableName: " + item.tableName()); + } + }) + .toList(); + return List.copyOf(normalized); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception); + } + } + + private StructuredDataTable normalize(StructuredDataTable value) { + if (value == null) { + throw new IllegalArgumentException("null 카탈로그 객체"); + } + String key = requiredText(value.key(), "key").toLowerCase(Locale.ROOT); + if (!KEY.matcher(key).matches()) { + throw new IllegalArgumentException("잘못된 key: " + value.key()); + } + String objectName = requireName(value.tableName(), "tableName"); + String objectType = requiredText(value.objectType(), "objectType").toUpperCase(Locale.ROOT); + if (!Set.of("TABLE", "VIEW").contains(objectType)) { + throw new IllegalArgumentException("잘못된 objectType: " + value.objectType()); + } + List previewColumns = value.previewColumns() == null + ? List.of() + : value.previewColumns().stream() + .map(column -> requireName(column, "previewColumns")) + .distinct() + .toList(); + return new StructuredDataTable( + key, + objectName, + objectType, + requiredText(value.businessName(), "businessName"), + requiredText(value.description(), "description"), + List.copyOf(previewColumns) + ); + } + + private String requireName(String value, String field) { + String normalized = requiredText(value, field).toUpperCase(Locale.ROOT); + if (!NAME.matcher(normalized).matches()) { + throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다."); + } + return normalized; + } + + private String requiredText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " 값은 필수입니다."); + } + return value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java new file mode 100644 index 0000000..e6559ab --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java @@ -0,0 +1,75 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.MaskingProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Loads the managed Data Redaction allow-list from deployment configuration. */ +@Service +public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog { + + private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + + private final List targets; + + public EnvironmentMaskingPolicyCatalog( + MaskingProperties properties, + ObjectMapper objectMapper + ) { + targets = parse(properties.policies(), objectMapper); + } + + @Override + public List targets() { + return targets; + } + + private List parse(String raw, ObjectMapper objectMapper) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + try { + List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("마스킹 정책 목록이 비어 있습니다."); + } + Set objectNames = new HashSet<>(); + Set policyNames = new HashSet<>(); + List normalized = parsed.stream() + .map(item -> { + if (item == null) { + throw new IllegalArgumentException("null 마스킹 정책"); + } + return new MaskingPolicyTarget( + normalize(item.objectName()), + normalize(item.policyName())); + }) + .peek(item -> { + if (!objectNames.add(item.objectName())) { + throw new IllegalArgumentException("중복 objectName: " + item.objectName()); + } + if (!policyNames.add(item.policyName())) { + throw new IllegalArgumentException("중복 policyName: " + item.policyName()); + } + }) + .toList(); + return List.copyOf(normalized); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_MASKING_POLICIES 설정을 확인하세요.", exception); + } + } + + private String normalize(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!NAME.matcher(normalized).matches()) { + throw new IllegalArgumentException("Oracle 식별자 형식이 올바르지 않습니다."); + } + return normalized; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java new file mode 100644 index 0000000..66caabf --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java @@ -0,0 +1,136 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.McpProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Loads MCP tool contracts while retaining the #722 single Select AI compatibility fields. */ +@Service +public class EnvironmentMcpToolCatalog implements McpToolCatalog { + + private static final Pattern TOOL_NAME = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final Pattern ARGUMENT_NAME = + Pattern.compile("[A-Za-z][A-Za-z0-9_]{0,63}"); + private static final Pattern ORACLE_NAME = + Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + + private final List tools; + + public EnvironmentMcpToolCatalog( + McpProperties properties, + ObjectMapper objectMapper + ) { + tools = parse(properties, objectMapper); + } + + @Override + public List tools() { + return tools; + } + + @Override + public McpToolDefinition require(String name) { + return tools.stream() + .filter(tool -> tool.name().equals(name)) + .findFirst() + .orElseThrow(() -> new AppException("등록되지 않은 MCP tool입니다: " + name)); + } + + private List parse( + McpProperties properties, + ObjectMapper objectMapper + ) { + String raw = properties.tools(); + if (raw == null || raw.isBlank()) { + return List.of(new McpToolDefinition( + properties.resolvedToolName(), + properties.resolvedToolLabel(), + properties.resolvedToolDescription(), + "prompt", + properties.resolvedPromptDescription(), + "SELECT_AI", + "", + "" + )); + } + try { + List parsed = + objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("MCP 도구 목록이 비어 있습니다."); + } + Set names = new HashSet<>(); + List normalized = parsed.stream() + .map(this::normalize) + .peek(tool -> { + if (!names.add(tool.name())) { + throw new IllegalArgumentException("중복 MCP tool name: " + tool.name()); + } + }) + .toList(); + return List.copyOf(normalized); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_MCP_TOOLS 설정을 확인하세요.", exception); + } + } + + private McpToolDefinition normalize(McpToolDefinition value) { + if (value == null) { + throw new IllegalArgumentException("null MCP tool"); + } + String name = required(value.name(), "name"); + String argumentName = required(value.argumentName(), "argumentName"); + if (!TOOL_NAME.matcher(name).matches()) { + throw new IllegalArgumentException("잘못된 MCP tool name"); + } + if (!ARGUMENT_NAME.matcher(argumentName).matches()) { + throw new IllegalArgumentException("잘못된 MCP argumentName"); + } + String executionType = required(value.executionType(), "executionType") + .toUpperCase(Locale.ROOT); + if (!Set.of("AGENT_TOOL", "SELECT_AI").contains(executionType)) { + throw new IllegalArgumentException("잘못된 MCP executionType"); + } + String targetName = value.targetName() == null ? "" : value.targetName().trim(); + String targetParameterName = + value.targetParameterName() == null ? "" : value.targetParameterName().trim(); + if ("AGENT_TOOL".equals(executionType)) { + targetName = oracleName(targetName, "targetName"); + targetParameterName = oracleName(targetParameterName, "targetParameterName"); + } else if (!targetName.isBlank() || !targetParameterName.isBlank()) { + throw new IllegalArgumentException("SELECT_AI에는 targetName을 지정할 수 없습니다."); + } + return new McpToolDefinition( + name, + required(value.label(), "label"), + required(value.description(), "description"), + argumentName, + required(value.argumentDescription(), "argumentDescription"), + executionType, + targetName, + targetParameterName + ); + } + + private String oracleName(String value, String field) { + String normalized = required(value, field).toUpperCase(Locale.ROOT); + if (!ORACLE_NAME.matcher(normalized).matches()) { + throw new IllegalArgumentException("잘못된 " + field); + } + return normalized; + } + + private String required(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " 값은 필수입니다."); + } + return value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java new file mode 100644 index 0000000..d224b67 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java @@ -0,0 +1,22 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +public interface MaskingPolicyCatalog { + + List targets(); + + default Set objectNames() { + return targets().stream() + .map(MaskingPolicyTarget::objectName) + .collect(Collectors.toUnmodifiableSet()); + } + + default boolean containsObject(String objectName) { + return objectName != null + && objectNames().contains(objectName.trim().toUpperCase(Locale.ROOT)); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index 8d21949..04c6b28 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -2,10 +2,8 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -29,38 +27,43 @@ public class MaskingPolicySynchronizer { private final JdbcTemplate jdbcTemplate; private final MaskingRuleMapper mapper; - private final StructuredDataCatalog catalog; - private final Map managedPolicies; + private final DataCatalog dataCatalog; + private final MaskingPolicyCatalog policyCatalog; public MaskingPolicySynchronizer( JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper, - StructuredDataCatalogProvider catalogProvider + DataCatalog dataCatalog, + MaskingPolicyCatalog policyCatalog ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - this.catalog = catalogProvider.catalog(); - Map policies = new LinkedHashMap<>(); - catalog.tables().stream() - .filter(table -> table.maskingPolicyName() != null) - .forEach(table -> policies.put(table.tableName(), table.maskingPolicyName())); - this.managedPolicies = Collections.unmodifiableMap(policies); + this.dataCatalog = dataCatalog; + this.policyCatalog = policyCatalog; } public Set managedObjectNames() { - return managedPolicies.keySet(); + return policyCatalog.objectNames(); } public String owner() { - return catalog.owner(); + return dataCatalog.owner(); } public boolean isManagedObject(String objectName) { - return objectName != null && managedPolicies.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); + return policyCatalog.containsObject(objectName); } String managedPolicyName(String objectName) { - return managedPolicies.get(objectName); + return policyCatalog.targets().stream() + .filter(target -> target.objectName().equals(objectName)) + .map(MaskingPolicyTarget::policyName) + .findFirst() + .orElse(null); + } + + List managedPolicies() { + return policyCatalog.targets(); } /** @@ -73,9 +76,9 @@ public class MaskingPolicySynchronizer { public MaskingPolicySyncResult synchronize() { Map> desiredByObject = new LinkedHashMap<>(); for (ColumnMaskingRule rule : mapper.findColumnRules()) { - if (catalog.owner().equalsIgnoreCase(rule.owner()) + if (dataCatalog.owner().equalsIgnoreCase(rule.owner()) && rule.ruleEnabled() - && managedPolicies.containsKey(rule.objectName())) { + && policyCatalog.containsObject(rule.objectName())) { desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); } } @@ -85,9 +88,9 @@ public class MaskingPolicySynchronizer { int addedColumns = 0; int modifiedColumns = 0; int droppedColumns = 0; - for (Map.Entry policy : managedPolicies.entrySet()) { - String objectName = policy.getKey(); - String policyName = policy.getValue(); + for (MaskingPolicyTarget policy : policyCatalog.targets()) { + String objectName = policy.objectName(); + String policyName = policy.policyName(); List desired = desiredByObject.getOrDefault(objectName, List.of()); String enableStatus = policyEnableStatus(objectName, policyName); if (desired.isEmpty()) { @@ -147,7 +150,7 @@ public class MaskingPolicySynchronizer { SELECT enable FROM redaction_policies WHERE object_owner = ? AND object_name = ? AND policy_name = ? - """, String.class, catalog.owner(), objectName, policyName); + """, String.class, dataCatalog.owner(), objectName, policyName); return statuses.isEmpty() ? null : statuses.getFirst(); } @@ -156,7 +159,7 @@ public class MaskingPolicySynchronizer { SELECT column_name FROM redaction_columns WHERE object_owner = ? AND object_name = ? - """, String.class, catalog.owner(), objectName).stream() + """, String.class, dataCatalog.owner(), objectName).stream() .map(this::requiredColumnName) .toList(); } @@ -166,7 +169,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, catalog.owner(), objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void enablePolicy(String objectName, String policyName) { @@ -174,7 +177,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, catalog.owner(), objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void dropColumn(String objectName, String policyName, String columnName) { @@ -185,7 +188,7 @@ public class MaskingPolicySynchronizer { action => DBMS_REDACT.DROP_COLUMN, column_name => ? ); END; - """, catalog.owner(), objectName, policyName, columnName); + """, dataCatalog.owner(), objectName, policyName, columnName); } private void addPolicy( @@ -257,9 +260,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } return; } @@ -282,9 +285,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(actionConstant, functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } } @@ -320,7 +323,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, catalog.owner(), objectName, columnName, expressionName); + """, dataCatalog.owner(), objectName, columnName, expressionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java new file mode 100644 index 0000000..2f60dc3 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java @@ -0,0 +1,5 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** A validated database object-to-redaction-policy mapping. */ +public record MaskingPolicyTarget(String objectName, String policyName) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index 92621fd..b520f56 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -6,7 +6,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; -import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; @@ -58,10 +57,10 @@ public class MaskingRuleService { /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */ public List findPolicyStatuses() { - List policies = maskingPolicySynchronizer.managedObjectNames().stream() - .map(objectName -> new ManagedMaskingPolicy( - objectName, maskingPolicySynchronizer.managedPolicyName(objectName))) - .toList(); + List policies = maskingPolicySynchronizer.managedPolicies(); + if (policies.isEmpty()) { + return List.of(); + } return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 2330ce8..7c2705b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -1,5 +1,7 @@ package com.cloudhandson.vpdbackoffice.service; +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -8,45 +10,35 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; -/** Exposes the same read-only HMM HR tool contract as hmm-mcp.cloud-handson.com. */ +/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */ @Service public class McpSseService { - private static final List HMM_TOOLS = List.of( - new ToolSpec( - "resolve_hr_term", - "HMM_HR_TERM_RESOLVER", - "term", - "P_TERM", - "휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.", - "HMM HR 용어 표준화"), - new ToolSpec( - "search_hr_data", - "HMM_HR_NORMALIZED_DATA_SEARCH", - "query", - "P_QUERY", - "조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.", - "HMM HR 데이터 조회"), - new ToolSpec( - "search_hr_policy", - "HMM_HR_POLICY_SEARCH", - "query", - "P_QUERY", - "HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.", - "HMM HR 규정 검색") - ); + private static final String MCP_CALL_PATH = "/mcp (tools/call)"; private final HmmAiAgentToolRunner agentToolRunner; + private final SelectAiService selectAiService; private final HmmMcpBearerAuthenticator bearerAuthenticator; + private final McpToolCatalog toolCatalog; + private final McpProperties mcpProperties; + private final BackofficeProperties backofficeProperties; private final ObjectMapper objectMapper; public McpSseService( HmmAiAgentToolRunner agentToolRunner, + SelectAiService selectAiService, HmmMcpBearerAuthenticator bearerAuthenticator, + McpToolCatalog toolCatalog, + McpProperties mcpProperties, + BackofficeProperties backofficeProperties, ObjectMapper objectMapper ) { this.agentToolRunner = agentToolRunner; + this.selectAiService = selectAiService; this.bearerAuthenticator = bearerAuthenticator; + this.toolCatalog = toolCatalog; + this.mcpProperties = mcpProperties; + this.backofficeProperties = backofficeProperties; this.objectMapper = objectMapper; } @@ -54,7 +46,7 @@ public class McpSseService { return handle(contextPath, request, ""); } - /** Validates the user bearer before serving discovery or executing a tool. */ + /** Discovery and execution use the same HMM business-user Bearer token boundary. */ public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) { bearerAuthenticator.authenticate(bearerToken); ObjectNode response = objectMapper.createObjectNode(); @@ -63,8 +55,10 @@ public class McpSseService { response.set("id", request.get("id")); } - String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); - JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params"); + String method = + request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); + JsonNode parameters = + request == null ? objectMapper.createObjectNode() : request.path("params"); try { response.set("result", switch (method) { case "initialize" -> initializeResult(contextPath); @@ -73,20 +67,27 @@ public class McpSseService { case "tools/call" -> toolsCallResult(parameters, bearerToken); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); }); + } catch (McpUnauthorizedException exception) { + throw exception; } catch (Exception exception) { response.remove("result"); ObjectNode error = objectMapper.createObjectNode(); error.put("code", -32000); - error.put("message", exception.getMessage()); + error.put("message", safeMessage(exception)); response.set("error", error); } return response; } public List registeredTools() { - return HMM_TOOLS.stream() + return toolCatalog.tools().stream() .map(tool -> new McpToolView( - tool.name(), tool.description(), -1L, tool.displayName(), tool.agentToolName())) + tool.name(), + tool.description(), + -1L, + tool.label(), + tool.agentTool() ? tool.targetName() : MCP_CALL_PATH + )) .toList(); } @@ -94,8 +95,8 @@ public class McpSseService { ObjectNode result = objectMapper.createObjectNode(); result.put("protocolVersion", "2024-11-05"); ObjectNode serverInfo = objectMapper.createObjectNode(); - serverInfo.put("name", "hmm-hr-backoffice-" + contextPath); - serverInfo.put("version", "1.0.0"); + serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath); + serverInfo.put("version", "1.1.0"); result.set("serverInfo", serverInfo); ObjectNode capabilities = objectMapper.createObjectNode(); capabilities.set("tools", objectMapper.createObjectNode()); @@ -106,12 +107,12 @@ public class McpSseService { private ObjectNode toolsListResult() { ObjectNode result = objectMapper.createObjectNode(); ArrayNode tools = objectMapper.createArrayNode(); - HMM_TOOLS.forEach(tool -> tools.add(toolDefinition(tool))); + toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool))); result.set("tools", tools); return result; } - private ObjectNode toolDefinition(ToolSpec tool) { + private ObjectNode toolDefinition(McpToolDefinition tool) { ObjectNode item = objectMapper.createObjectNode(); item.put("name", tool.name()); item.put("description", tool.description()); @@ -133,23 +134,34 @@ public class McpSseService { } private ObjectNode toolsCallResult(JsonNode params, String bearerToken) { - String requestedName = params.path("name").asText(""); - ToolSpec tool = HMM_TOOLS.stream() - .filter(candidate -> candidate.name().equals(requestedName)) - .findFirst() - .orElseThrow(() -> new AppException("등록되지 않은 HMM MCP tool입니다: " + requestedName)); + McpToolDefinition tool = toolCatalog.require(params.path("name").asText("")); String argument = params.path("arguments").path(tool.argumentName()).asText("").trim(); if (argument.isBlank()) { throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다."); } - ObjectNode input = objectMapper.createObjectNode(); - input.put(tool.agentParameterName(), argument); - JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken); + + JsonNode toolResponse; + if (tool.agentTool()) { + ObjectNode input = objectMapper.createObjectNode(); + input.put(tool.targetParameterName(), argument); + toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken); + } else { + toolResponse = selectAiService.generateAndExecute(bearerToken, argument); + } ObjectNode payload = objectMapper.createObjectNode(); payload.put("toolName", tool.name()); - payload.put("agentTool", tool.agentToolName()); + payload.put("executionType", tool.executionType()); + if (tool.agentTool()) { + payload.put("agentTool", tool.targetName()); + } else { + BackofficeProperties.SelectAi selectAi = + backofficeProperties == null ? null : backofficeProperties.selectAi(); + payload.put("profile", selectAi == null || selectAi.profile() == null + ? "" : selectAi.profile()); + } payload.set("response", toolResponse); + ObjectNode result = objectMapper.createObjectNode(); ArrayNode content = objectMapper.createArrayNode(); ObjectNode text = objectMapper.createObjectNode(); @@ -161,6 +173,11 @@ public class McpSseService { return result; } + private String safeMessage(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message; + } + private String pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); @@ -168,19 +185,4 @@ public class McpSseService { return String.valueOf(value); } } - - private record ToolSpec( - String name, - String agentToolName, - String argumentName, - String agentParameterName, - String description, - String displayName - ) { - String argumentDescription() { - return "term".equals(argumentName) - ? "확인할 휴가·근태 용어, 동의어 또는 코드입니다." - : "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다."; - } - } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java new file mode 100644 index 0000000..2c68bbb --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; + +public interface McpToolCatalog { + + List tools(); + + McpToolDefinition require(String name); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java new file mode 100644 index 0000000..b2f1487 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java @@ -0,0 +1,22 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Validated MCP tool contract supplied by deployment configuration. */ +public record McpToolDefinition( + String name, + String label, + String description, + String argumentName, + String argumentDescription, + String executionType, + String targetName, + String targetParameterName +) { + + public boolean agentTool() { + return "AGENT_TOOL".equals(executionType); + } + + public boolean selectAi() { + return "SELECT_AI".equals(executionType); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index cd00697..256bc8d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -3,7 +3,6 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -35,7 +34,7 @@ public class SchemaMetadataService { return structuredDataService.tables(); } - public StructuredDataCatalog catalog() { + public DataCatalog catalog() { return structuredDataService.catalog(); } @@ -47,7 +46,8 @@ public class SchemaMetadataService { StructuredDataTable table = structuredDataService.requireTable(tableKey); String tableName = table.tableName(); String tableComment = tableComment(tableName); - Map> annotations = annotationsByTarget(tableName); + Map> annotations = + table.isTable() ? annotationsByTarget(tableName) : Map.of(); List columns = columns(tableName, annotations); return new SchemaMetadataView( table, @@ -77,6 +77,7 @@ public class SchemaMetadataService { @Transactional public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) { StructuredDataTable table = structuredDataService.requireTable(tableKey); + requireAnnotationTable(table); updateAnnotation(table.tableName(), null, annotationName, annotationValue); } @@ -88,6 +89,7 @@ public class SchemaMetadataService { String annotationValue ) { StructuredDataTable table = structuredDataService.requireTable(tableKey); + requireAnnotationTable(table); String column = requireColumn(table.tableName(), columnName); updateAnnotation(table.tableName(), column, annotationName, annotationValue); } @@ -227,6 +229,12 @@ public class SchemaMetadataService { return column; } + private void requireAnnotationTable(StructuredDataTable table) { + if (!table.isTable()) { + throw new AppException("Oracle annotation은 TABLE 객체에서만 수정할 수 있습니다."); + } + } + private String requireSimpleName(String value, String label) { if (value == null || value.isBlank()) { throw new AppException(label + "은(는) 필수입니다."); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java index fab300f..1550c72 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -1,11 +1,17 @@ package com.cloudhandson.vpdbackoffice.service; +import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; @@ -17,46 +23,21 @@ import org.springframework.stereotype.Service; @Service public class SecuritySqlScriptService { - private static final List CURATED_SCRIPTS = List.of( - new ScriptDefinition( - "aso-masking-metadata", - "ASO / 마스킹", - "62_kb_aso_masking_backoffice_metadata.sql", - "컬럼 마스킹 규칙 메타데이터", - "ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다." - ), - new ScriptDefinition( - "aso-masking-runtime", - "ASO / 마스킹", - "63_kb_aso_masking_rule_runtime.sql", - "ASO 마스킹 런타임 적용", - "백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다." - ), - new ScriptDefinition( - "aso-masking-default-columns", - "ASO / 마스킹", - "64_kb_aso_masking_default_column_rules.sql", - "ASO 기본 대상 컬럼", - "주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다." - ), - new ScriptDefinition( - "select-ai-vpd-api", - "Select AI / 행 접근", - "65_kb_select_ai_vpd_query_api.sql", - "행 접근 적용 Select AI 조회 API", - "생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다." - ), - new ScriptDefinition( - "select-ai-vpd-ords", - "ORDS / Select AI", - "66_kb_select_ai_vpd_query_ords.sql", - "Select AI 행 접근 ORDS Endpoint", - "Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다." - ) + private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + private static final Pattern RESOURCE_PATH = Pattern.compile( + "(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql" ); + private final List scripts; + + public SecuritySqlScriptService( + SecuritySqlScriptProperties properties, + ObjectMapper objectMapper + ) { + scripts = parse(properties.scripts(), objectMapper); + } public List list() { - return CURATED_SCRIPTS.stream() + return scripts.stream() .map(definition -> new SecuritySqlScriptSummary( definition.scriptId(), definition.category(), @@ -68,7 +49,7 @@ public class SecuritySqlScriptService { } public SecuritySqlScript find(String scriptId) { - ScriptDefinition definition = CURATED_SCRIPTS.stream() + ScriptDefinition definition = scripts.stream() .filter(candidate -> candidate.scriptId().equals(scriptId)) .findFirst() .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); @@ -91,7 +72,48 @@ public class SecuritySqlScriptService { } } - private record ScriptDefinition( + private List parse(String raw, ObjectMapper objectMapper) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + try { + List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다."); + } + Set scriptIds = new HashSet<>(); + Set fileNames = new HashSet<>(); + parsed.forEach(definition -> { + validate(definition); + if (!scriptIds.add(definition.scriptId())) { + throw new IllegalArgumentException("중복 scriptId"); + } + if (!fileNames.add(definition.fileName())) { + throw new IllegalArgumentException("중복 fileName"); + } + }); + return List.copyOf(parsed); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception); + } + } + + private void validate(ScriptDefinition definition) { + if (definition == null + || definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches() + || definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches() + || blank(definition.category()) + || blank(definition.title()) + || blank(definition.description())) { + throw new IllegalArgumentException("보안 SQL 정의가 올바르지 않습니다."); + } + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } + + public record ScriptDefinition( String scriptId, String category, String fileName, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java new file mode 100644 index 0000000..e7ae37a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -0,0 +1,232 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Generates and executes bounded read-only SQL through a configured Select AI profile. */ +@Service +public class SelectAiService { + + private static final int MAX_PROMPT_LENGTH = 4_000; + private static final int MAX_RESULT_ROWS = 100; + private static final int QUERY_TIMEOUT_SECONDS = 30; + private static final Pattern UNSAFE_SQL = Pattern.compile( + "(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|" + + "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|" + + "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b" + ); + + private final BackofficeProperties properties; + private final HmmMcpBearerAuthenticator bearerAuthenticator; + private final ObjectMapper objectMapper; + + public SelectAiService( + BackofficeProperties properties, + HmmMcpBearerAuthenticator bearerAuthenticator, + ObjectMapper objectMapper + ) { + this.properties = properties; + this.bearerAuthenticator = bearerAuthenticator; + this.objectMapper = objectMapper; + } + + public JsonNode generateAndExecute(String bearerToken, String prompt) { + bearerAuthenticator.authenticate(bearerToken); + String normalizedPrompt = requiredPrompt(prompt); + BackofficeProperties.SelectAi selectAi = + properties == null ? null : properties.selectAi(); + if (selectAi == null || !selectAi.configured()) { + throw new AppException("Select AI 연결 설정이 필요합니다. " + + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + + "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요."); + } + + String generatedSql = generate(selectAi, normalizedPrompt); + String normalizedSql = validateReadOnlySql(generatedSql); + QueryExecution execution = executeReadOnly( + selectAi, normalizedSql, bearerToken.trim()); + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", "SHOWSQL_AND_EXECUTED"); + response.put("profile", selectAi.profile()); + response.put("generatedSql", normalizedSql); + response.put("execution", "READ_ONLY_EXECUTED"); + response.put("rowCount", execution.items().size()); + response.put("truncated", execution.truncated()); + response.set("items", execution.items()); + response.put("nextStep", execution.truncated() + ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다." + : "생성 SQL을 읽기 전용으로 실행한 결과입니다."); + return response; + } + + private String requiredPrompt(String prompt) { + String normalized = prompt == null ? "" : prompt.trim(); + if (normalized.isEmpty()) { + throw new AppException("prompt는 필수입니다."); + } + if (normalized.length() > MAX_PROMPT_LENGTH) { + throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다."); + } + return normalized; + } + + private String generate( + BackofficeProperties.SelectAi selectAi, + String prompt + ) { + String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual"; + try (Connection connection = DriverManager.getConnection( + selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, prompt); + statement.setString(2, selectAi.profile()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next() || resultSet.getString(1) == null) { + throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다."); + } + return resultSet.getString(1); + } + } catch (AppException exception) { + throw exception; + } catch (Exception exception) { + throw new AppException("Select AI SHOWSQL 생성 실패: " + safeMessage(exception)); + } + } + + private QueryExecution executeReadOnly( + BackofficeProperties.SelectAi selectAi, + String generatedSql, + String bearerToken + ) { + ArrayNode items = objectMapper.createArrayNode(); + boolean truncated = false; + try (Connection connection = DriverManager.getConnection( + selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) { + boolean contextSet = false; + try { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) { + statement.setString(1, bearerToken); + statement.execute(); + contextSet = true; + } + connection.setAutoCommit(false); + connection.setReadOnly(true); + try (Statement transaction = connection.createStatement()) { + transaction.execute("SET TRANSACTION READ ONLY"); + } + try (PreparedStatement statement = connection.prepareStatement(generatedSql)) { + statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); + statement.setFetchSize(MAX_RESULT_ROWS + 1); + statement.setMaxRows(MAX_RESULT_ROWS + 1); + try (ResultSet resultSet = statement.executeQuery()) { + ResultSetMetaData metadata = resultSet.getMetaData(); + while (resultSet.next()) { + if (items.size() >= MAX_RESULT_ROWS) { + truncated = true; + break; + } + ObjectNode row = items.addObject(); + for (int columnIndex = 1; + columnIndex <= metadata.getColumnCount(); + columnIndex++) { + String column = metadata.getColumnLabel(columnIndex); + if (column == null || column.isBlank()) { + column = metadata.getColumnName(columnIndex); + } + putResultValue(row, column, resultSet.getObject(columnIndex)); + } + } + } + } + } finally { + try { + connection.rollback(); + } finally { + if (contextSet) { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + statement.execute(); + } + } + } + } + } catch (Exception exception) { + throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception)); + } + return new QueryExecution(items, truncated); + } + + private void putResultValue(ObjectNode row, String column, Object value) { + if (value == null) { + row.putNull(column); + } else if (value instanceof BigDecimal number) { + row.put(column, number); + } else if (value instanceof BigInteger number) { + row.put(column, number); + } else if (value instanceof Integer number) { + row.put(column, number); + } else if (value instanceof Long number) { + row.put(column, number); + } else if (value instanceof Short number) { + row.put(column, number); + } else if (value instanceof Float number) { + row.put(column, number); + } else if (value instanceof Double number) { + row.put(column, number); + } else if (value instanceof Boolean bool) { + row.put(column, bool); + } else { + row.put(column, String.valueOf(value)); + } + } + + String validateReadOnlySql(String generatedSql) { + String normalized = generatedSql == null ? "" : generatedSql.trim(); + if (normalized.startsWith("```")) { + int firstLineEnd = normalized.indexOf('\n'); + int closingFence = normalized.lastIndexOf("```"); + if (firstLineEnd >= 0 && closingFence > firstLineEnd) { + normalized = normalized.substring(firstLineEnd + 1, closingFence).trim(); + } + } + normalized = normalized.replaceFirst(";\\s*$", "").trim(); + if (!normalized.matches("(?is)^(select|with)\\b.*")) { + throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다."); + } + if (normalized.contains(";")) { + throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 실행하지 않습니다."); + } + if (normalized.contains("--") + || normalized.contains("/*") + || normalized.contains("*/") + || UNSAFE_SQL.matcher(normalized).find()) { + throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다."); + } + return normalized; + } + + private String safeMessage(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() + ? exception.getClass().getSimpleName() + : message; + } + + private record QueryExecution(ArrayNode items, boolean truncated) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java deleted file mode 100644 index 4d0166c..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Component; - -@Component -public class StructuredDataCatalogProvider { - - private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); - private static final Pattern TABLE_KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}"); - private static final int MAX_ROW_LIMIT = 500; - - private final StructuredDataCatalog catalog; - - public StructuredDataCatalogProvider( - ObjectMapper objectMapper, - @Value("${backoffice.structured-data.catalog-location:classpath:/config/structured-data-catalog.json}") - Resource catalogResource - ) { - try (var input = catalogResource.getInputStream()) { - this.catalog = validate(objectMapper.readValue(input, StructuredDataCatalog.class)); - } catch (IOException exception) { - throw new IllegalStateException( - "정형 데이터 카탈로그 JSON을 읽을 수 없습니다: " + catalogResource.getDescription(), exception); - } - } - - public StructuredDataCatalog catalog() { - return catalog; - } - - private StructuredDataCatalog validate(StructuredDataCatalog source) { - if (source == null) { - throw new IllegalStateException("정형 데이터 카탈로그가 비어 있습니다."); - } - String sourceName = requireText(source.sourceName(), "sourceName"); - String owner = requireOracleName(source.owner(), "owner"); - String pageHelp = requireText(source.pageHelp(), "pageHelp"); - String catalogDescription = requireText(source.catalogDescription(), "catalogDescription"); - if (source.rowLimit() < 1 || source.rowLimit() > MAX_ROW_LIMIT) { - throw new IllegalStateException("정형 데이터 카탈로그 rowLimit은 1~" + MAX_ROW_LIMIT + " 범위여야 합니다."); - } - if (source.tables() == null || source.tables().isEmpty()) { - throw new IllegalStateException("정형 데이터 카탈로그에는 테이블이 한 개 이상 필요합니다."); - } - - Set keys = new HashSet<>(); - Set tableNames = new HashSet<>(); - List tables = source.tables().stream().map(table -> { - if (table == null) { - throw new IllegalStateException("정형 데이터 카탈로그에 null 테이블 정의가 있습니다."); - } - String key = requireKey(table.key()); - String tableName = requireOracleName(table.tableName(), "tableName"); - if (!keys.add(key)) { - throw new IllegalStateException("정형 데이터 카탈로그 key가 중복됩니다: " + key); - } - if (!tableNames.add(tableName)) { - throw new IllegalStateException("정형 데이터 카탈로그 tableName이 중복됩니다: " + tableName); - } - List previewColumns = table.previewColumns() == null - ? List.of() - : table.previewColumns().stream() - .map(column -> requireOracleName(column, "previewColumns")) - .distinct() - .toList(); - String maskingPolicyName = table.maskingPolicyName() == null || table.maskingPolicyName().isBlank() - ? null - : requireOracleName(table.maskingPolicyName(), "maskingPolicyName"); - return new StructuredDataTable( - key, - tableName, - requireText(table.businessName(), "businessName"), - requireText(table.description(), "description"), - List.copyOf(previewColumns), - maskingPolicyName); - }).toList(); - - return new StructuredDataCatalog( - sourceName, owner, pageHelp, catalogDescription, source.rowLimit(), List.copyOf(tables)); - } - - private String requireKey(String value) { - String normalized = requireText(value, "key").toLowerCase(Locale.ROOT); - if (!TABLE_KEY.matcher(normalized).matches()) { - throw new IllegalStateException("정형 데이터 카탈로그 key 형식이 올바르지 않습니다: " + value); - } - return normalized; - } - - private String requireOracleName(String value, String field) { - String normalized = requireText(value, field).toUpperCase(Locale.ROOT); - if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) { - throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 형식이 올바르지 않습니다: " + value); - } - return normalized; - } - - private String requireText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 값은 필수입니다."); - } - return value.trim(); - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index d13ad92..09f121d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -1,6 +1,5 @@ package com.cloudhandson.vpdbackoffice.service; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.List; @@ -12,18 +11,20 @@ import org.springframework.stereotype.Service; @Service public class StructuredDataService { + private static final int ROW_LIMIT = 50; + private final JdbcTemplate jdbcTemplate; - private final StructuredDataCatalog catalog; + private final DataCatalog catalog; public StructuredDataService( JdbcTemplate jdbcTemplate, - StructuredDataCatalogProvider catalogProvider + DataCatalog catalog ) { this.jdbcTemplate = jdbcTemplate; - this.catalog = catalogProvider.catalog(); + this.catalog = catalog; } - public StructuredDataCatalog catalog() { + public DataCatalog catalog() { return catalog; } @@ -32,18 +33,15 @@ public class StructuredDataService { } public List tables() { - return catalog.tables(); + return catalog.objects(); } public String defaultKey() { - return catalog.tables().getFirst().key(); + return catalog.objects().getFirst().key(); } public StructuredDataTable requireTable(String key) { - return catalog.tables().stream() - .filter(table -> table.key().equals(key)) - .findFirst() - .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); + return catalog.require(key); } public StructuredDataPreview preview(String key) { @@ -61,17 +59,17 @@ public class StructuredDataService { if (columns.isEmpty()) { throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); } - List previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns(); + List previewColumns = + table.previewColumns().isEmpty() ? columns : table.previewColumns(); if (!columns.containsAll(previewColumns)) { - throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다."); + throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다."); } - List> rows = jdbcTemplate.queryForList( - previewSql(table, previewColumns), catalog.rowLimit()); - return new StructuredDataPreview(table, previewColumns, rows, catalog.rowLimit()); + previewSql(table, previewColumns), ROW_LIMIT); + return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT); } catch (DataAccessException exception) { - throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.sourceName() + "의 " - + catalog.owner() + " 조회 권한과 대상 테이블 상태를 확인하세요."); + throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner() + + " 조회 권한과 대상 객체 상태를 확인하세요."); } } @@ -83,15 +81,20 @@ public class StructuredDataService { return previewSql(table, table.previewColumns()); } - private String previewSql(StructuredDataTable table, List previewColumns) { + private String previewSql( + StructuredDataTable table, + List previewColumns + ) { StructuredDataTable approved = requireTable(table.key()); if (!approved.tableName().equals(table.tableName())) { - throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); + throw new AppException("선택할 수 없는 카탈로그 객체입니다."); } String projection = previewColumns == null || previewColumns.isEmpty() ? "*" - : previewColumns.stream().map(column -> "\"" + column + "\"") - .reduce((left, right) -> left + ", " + right).orElseThrow(); + : previewColumns.stream() + .map(column -> "\"" + column + "\"") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(); return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName() + "\" WHERE ROWNUM <= ?"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java index 8f00887..c72f894 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java @@ -27,7 +27,7 @@ public class DashboardController { @GetMapping("/") public String dashboard(Model model) { - // The Smilegate PoC home is an identity-administration landing page. + // The backoffice home is an identity-administration landing page. // It intentionally does not query legacy CB_* VPD catalog objects. model.addAttribute("users", userService.findAll()); model.addAttribute("groups", groupService.findAll()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index efff248..3f32f95 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -1,6 +1,6 @@ package com.cloudhandson.vpdbackoffice.web; -import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import com.cloudhandson.vpdbackoffice.service.BearerTokenService; @@ -24,7 +24,7 @@ public class McpReasoningController { private final McpReasoningService reasoningService; private final BearerTokenService tokenService; private final UserMapper userMapper; - private final BackofficeProperties properties; + private final McpProperties mcpProperties; public McpReasoningController( McpToolRegistry toolRegistry, @@ -32,14 +32,14 @@ public class McpReasoningController { McpReasoningService reasoningService, BearerTokenService tokenService, UserMapper userMapper, - BackofficeProperties properties + McpProperties mcpProperties ) { this.toolRegistry = toolRegistry; this.mcpSseService = mcpSseService; this.reasoningService = reasoningService; this.tokenService = tokenService; this.userMapper = userMapper; - this.properties = properties; + this.mcpProperties = mcpProperties; } @GetMapping("/mcp-reasoning") @@ -65,7 +65,7 @@ public class McpReasoningController { @GetMapping("/mcp-sse") public String ssePage(Model model) { model.addAttribute("tools", mcpSseService.registeredTools()); - model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl()); + model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl()); return "mcp-sse"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java new file mode 100644 index 0000000..7af8a5e --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java @@ -0,0 +1,41 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.config.McpProperties; +import com.cloudhandson.vpdbackoffice.config.ProductProperties; +import com.cloudhandson.vpdbackoffice.service.DataCatalog; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +/** Supplies deployment labels to every server-rendered page. */ +@ControllerAdvice +public class ProductModelAdvice { + + private final ProductProperties product; + private final DataCatalog catalog; + private final McpProperties mcp; + + public ProductModelAdvice( + ProductProperties product, + DataCatalog catalog, + McpProperties mcp + ) { + this.product = product; + this.catalog = catalog; + this.mcp = mcp; + } + + @ModelAttribute("product") + ProductProperties product() { + return product; + } + + @ModelAttribute("catalogOwner") + String catalogOwner() { + return catalog.owner(); + } + + @ModelAttribute("mcp") + McpProperties mcp() { + return mcp; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java index 2206745..e5815b9 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java @@ -2,7 +2,7 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService; import com.cloudhandson.vpdbackoffice.service.SettingService; -import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -16,21 +16,21 @@ public class SettingController { private final SettingService settingService; private final BackofficeSchemaService backofficeSchemaService; - private final BackofficeProperties properties; + private final McpProperties mcpProperties; public SettingController( SettingService settingService, BackofficeSchemaService backofficeSchemaService, - BackofficeProperties properties + McpProperties mcpProperties ) { this.settingService = settingService; this.backofficeSchemaService = backofficeSchemaService; - this.properties = properties; + this.mcpProperties = mcpProperties; } @GetMapping("/settings") public String settings(Model model) { - model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl()); + model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl()); try { model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl()); } catch (DataAccessException exception) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5769bf1..324b37b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,9 +33,6 @@ server: same-site: lax backoffice: - structured-data: - # Override with file:/... JSON to reuse the application for another company/data model. - catalog-location: ${BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION:classpath:/config/structured-data-catalog.json} security: admin-user: ${BACKOFFICE_ADMIN_USER:admin} admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin} @@ -52,8 +49,6 @@ backoffice: token: max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365} ords: - # HMM HR agent queries use DBMS_CLOUD_AI_AGENT through HMM MCP, not ORDS. - # Keep this empty unless an operator explicitly enables a legacy ORDS operation. base-url: ${BACKOFFICE_ORDS_BASE_URL:} timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s @@ -61,8 +56,6 @@ backoffice: url: ${BACKOFFICE_ORDS_DB_URL:} username: ${BACKOFFICE_ORDS_DB_USERNAME:} password: ${BACKOFFICE_ORDS_DB_PASSWORD:} - mcp: - public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp} ai: enabled: ${BACKOFFICE_AI_ENABLED:false} provider: ${BACKOFFICE_AI_PROVIDER:openai} @@ -75,3 +68,27 @@ backoffice: oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}} oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} + select-ai: + db-url: ${BACKOFFICE_SELECT_AI_DB_URL:} + db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:} + db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:} + profile: ${BACKOFFICE_SELECT_AI_PROFILE:} + catalog: + owner: ${BACKOFFICE_CATALOG_OWNER:} + objects: ${BACKOFFICE_CATALOG_OBJECTS:} + product: + name: ${BACKOFFICE_PRODUCT_NAME:Data & AI Backoffice} + title: ${BACKOFFICE_PRODUCT_TITLE:Data & AI Backoffice} + data-label: ${BACKOFFICE_PRODUCT_DATA_LABEL:업무 데이터} + mcp: + public-url: ${BACKOFFICE_MCP_PUBLIC_URL:${BACKOFFICE_HMM_MCP_PUBLIC_URL:/mcp}} + server-name: ${BACKOFFICE_MCP_SERVER_NAME:data-ai-backoffice} + tool-name: ${BACKOFFICE_MCP_TOOL_NAME:oracle.select_ai.data_text2sql} + tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL} + tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다.} + prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.} + tools: ${BACKOFFICE_MCP_TOOLS:} + masking: + policies: ${BACKOFFICE_MASKING_POLICIES:} + security-sql-scripts: + scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:} diff --git a/src/main/resources/config/structured-data-catalog.json b/src/main/resources/config/structured-data-catalog.json deleted file mode 100644 index 8a83dcd..0000000 --- a/src/main/resources/config/structured-data-catalog.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "sourceName": "HMMAIPOC", - "owner": "ADMIN", - "pageHelp": "HMM HR 데모의 승인된 조직·직원·휴가·근태 원장을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.", - "catalogDescription": "HMMAIPOC의 ADMIN 스키마에서 승인된 HMM HR 정형 테이블만 표시합니다.", - "rowLimit": 50, - "tables": [ - { - "key": "teams", - "tableName": "HMM_ORG_TEAMS", - "businessName": "조직 원장", - "description": "HMM HR 조직·팀 기본정보" - }, - { - "key": "employees", - "tableName": "HMM_HR_EMPLOYEES", - "businessName": "직원 원장", - "description": "직원·매니저·소속팀 정보", - "maskingPolicyName": "HMM_EMPLOYEE_PII_REDACT" - }, - { - "key": "leave-balances", - "tableName": "HMM_LEAVE_BALANCES", - "businessName": "휴가 잔여 원장", - "description": "직원별 연도·휴가 유형별 부여·사용·잔여 일수", - "maskingPolicyName": "HMM_LEAVE_BALANCE_REDACT" - }, - { - "key": "leave-requests", - "tableName": "HMM_LEAVE_REQUESTS", - "businessName": "휴가 신청 원장", - "description": "직원별 휴가 신청·승인 상태와 기간", - "maskingPolicyName": "HMM_LEAVE_REQUEST_REDACT" - }, - { - "key": "attendance-daily", - "tableName": "HMM_ATTENDANCE_DAILY", - "businessName": "일별 근태 원장", - "description": "직원별 출퇴근·근무 상태와 근무 시간", - "maskingPolicyName": "HMM_ATTENDANCE_REDACT" - }, - { - "key": "hr-terms", - "tableName": "HMM_HR_TERMS", - "businessName": "HR 표준 용어 원장", - "description": "휴가·근태 표준 코드, 명칭과 유사 표현", - "previewColumns": [ - "TERM_ID", - "TERM_CODE", - "TERM_KIND", - "CANONICAL_NAME", - "TERM_NAME", - "IS_CANONICAL", - "DESCRIPTION", - "EMBEDDED_AT" - ] - } - ] -} diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 89a084c..da84562 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -5,7 +5,7 @@ - HMM HR Access Console + Data & AI Backoffice @@ -15,7 +15,7 @@
@@ -46,8 +46,8 @@ - - + + @@ -55,11 +55,11 @@ - + - + @@ -79,20 +79,18 @@ - - + + - + @@ -108,9 +106,9 @@ tools/call parameter 예시 보기

tools/call Arguments

{
-  "term": "연차 이월"
+  "<tools/list의 argument 이름>": "<조회할 자연어 질문>"
 }
-

등록 도구는 resolve_hr_term, search_hr_data, search_hr_policy입니다. 표준 용어가 필요한 질문은 resolve_hr_term 결과를 사용해 데이터 또는 규정 검색을 이어갑니다.

+

도구명, 설명, argument 이름은 배포 환경에서 바뀔 수 있으므로 tools/list 결과를 기준으로 호출합니다.

@@ -144,9 +142,9 @@ "id": 3, "method": "tools/call", "params": { - "name": "search_hr_policy", + "name": "<tools/list의 name>", "arguments": { - "query": "연차 휴가 이월 기준과 제한을 알려줘." + "<required argument>": "조회할 자연어 질문" } } } diff --git a/src/main/resources/templates/operation-status.html b/src/main/resources/templates/operation-status.html index 37b11f2..db64ea6 100644 --- a/src/main/resources/templates/operation-status.html +++ b/src/main/resources/templates/operation-status.html @@ -159,8 +159,8 @@ - - + + diff --git a/src/main/resources/templates/roles.html b/src/main/resources/templates/roles.html index 795ffc0..69e39a4 100644 --- a/src/main/resources/templates/roles.html +++ b/src/main/resources/templates/roles.html @@ -8,7 +8,7 @@

역할

도움말 -

스마일게이트 Data & AI PoC 운영 역할을 관리합니다. 역할은 사용자에게 직접 부여하거나 그룹을 통해 부여할 수 있습니다.

+

Data & AI Backoffice의 운영 역할을 관리합니다. 역할은 사용자에게 직접 부여하거나 그룹을 통해 부여할 수 있습니다.

diff --git a/src/main/resources/templates/schema-metadata.html b/src/main/resources/templates/schema-metadata.html index 11ad0fc..f40c812 100644 --- a/src/main/resources/templates/schema-metadata.html +++ b/src/main/resources/templates/schema-metadata.html @@ -10,10 +10,10 @@
도움말

- OWNER의 승인된 업무 테이블 table/column comment와 Oracle annotation을 조회·수정합니다. + OWNER의 승인된 업무 TABLE/VIEW comment와 컬럼 comment를 조회·수정합니다. Select AI profile의 comments=true, annotations=true 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.

-

임의 스키마나 임의 테이블은 수정하지 않고, JSON 카탈로그가 승인한 업무 테이블만 대상으로 합니다.

+

Oracle annotation은 TABLE에서만 관리합니다. 임의 스키마나 임의 객체는 수정하지 않습니다.

@@ -24,7 +24,7 @@

테이블 선택

-

승인된 업무 원장만 표시합니다.

+

업무 데이터 카탈로그에 등록된 TABLE/VIEW만 표시합니다.

6
@@ -34,8 +34,8 @@ th:classappend="${entry.key() == selectedKey} ? ' is-selected'" th:href="@{/schema-metadata(table=${entry.key()})}"> 직원 원장 - HMM_HR_EMPLOYEES - 직원·조직 정보 + OBJECT_NAME + TABLE · 설명 @@ -43,10 +43,10 @@
- TABLE + TABLE

직원 원장

- OWNER.TABLE_NAME + OWNER.OBJECT_NAME · 설명

@@ -63,13 +63,13 @@ -
+

Table annotations

기존 annotation은 값 수정 또는 빈 값 저장으로 삭제할 수 있습니다.

-
+
HMM MCPhttps://hmm-backoffice.cloud-handson.com/mcpMCPhttps://example.com/mcp
Transport
백오피스 호환 Endpoint/mcp — 로그인 세션에서 같은 HMM 도구 계약을 확인합니다./mcp — 같은 설정 기반 도구 계약을 제공합니다.
AuthAuthorization: Bearer <HMM MCP Token> — 공개 MCP 서버의 고정 인증 토큰입니다.Authorization: Bearer <사용자 토큰> — 활성 업무 사용자 토큰입니다.
Methods
Name ObjectAgent ToolInstruction / parameter mapping실행 대상Instruction
ords.query.admin.board_posts ADMIN.BOARD_POSTSHMM_HR_TERM_RESOLVERAGENT_TOOL_OR_SELECT_AI
ORDS 행 접근 조회 도구 설명
- - resolve_hr_term.term → 표준 용어·코드 · search_hr_data.query → HR 데이터 · search_hr_policy.query → 규정 PDF 검색 - + 정확한 argument 이름과 필수 여부는 tools/list.inputSchema를 사용합니다.
보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼상태확인 결과
ADMIN.HMM_HR_EMPLOYEESHMM_EMPLOYEE_PII_REDACTOWNER.OBJECT_NAMEREDACTION_POLICY 0 0 적용됨
@@ -146,7 +146,7 @@ -
+
AnnotationValue
diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 9cdd944..2fe8295 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -8,7 +8,7 @@

시스템 설정

도움말 -

HMM HR 질의와 Agent Factory는 HMM MCP를 사용합니다. ORDS는 기존 VPD/ORDS 운영 기능이 필요한 경우에만 별도로 설정합니다.

+

Data & AI Backoffice의 MCP 공개 주소와 선택형 레거시 ORDS 연결을 확인합니다.

@@ -22,9 +22,9 @@
- HMM MCP · 현재 사용 -

HMM HR Agent 도구

-

용어 정규화, HR 데이터 조회, HR 정책 문서 검색은 이 MCP의 DBMS_CLOUD_AI_AGENT 도구를 사용합니다.

+ MCP · 현재 사용 +

설정 기반 MCP 도구

+

환경에서 승인한 도구 계약과 사용자 Bearer Token 인증을 사용합니다.

@@ -85,8 +85,8 @@
- - + + diff --git a/src/main/resources/templates/users.html b/src/main/resources/templates/users.html index 0212fa7..a81a2ca 100644 --- a/src/main/resources/templates/users.html +++ b/src/main/resources/templates/users.html @@ -8,7 +8,7 @@

사용자

도움말 -

스마일게이트 Data & AI PoC 도구를 사용할 운영 사용자를 관리합니다. 게임 서비스 이용자나 Oracle DB 계정과는 별개의 백오피스 관리 대상입니다.

+

Data & AI Backoffice에서 사용할 운영 사용자를 관리합니다. 업무 시스템 사용자와 Oracle DB 계정은 별개로 관리됩니다.

diff --git a/src/main/resources/templates/vpd-filter-runtime.html b/src/main/resources/templates/vpd-filter-runtime.html index 805c99f..c1238f5 100644 --- a/src/main/resources/templates/vpd-filter-runtime.html +++ b/src/main/resources/templates/vpd-filter-runtime.html @@ -82,8 +82,8 @@ - - + + diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java index c91b0c4..96ce232 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java @@ -3,10 +3,11 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import com.cloudhandson.vpdbackoffice.config.CatalogProperties; +import com.cloudhandson.vpdbackoffice.config.MaskingProperties; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; -import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; class MaskingPolicySynchronizerTest { @@ -28,11 +29,24 @@ class MaskingPolicySynchronizerTest { } @Test - void hmmEmployeePolicyComesFromTheSharedJsonCatalog() { - var catalogProvider = new StructuredDataCatalogProvider( - new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json")); + void hmmEmployeePolicyComesFromEnvironmentPolicyCatalog() { + ObjectMapper objectMapper = new ObjectMapper(); + var dataCatalog = new EnvironmentDataCatalog( + new CatalogProperties("ADMIN", """ + [{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE", + "businessName":"직원","description":"직원"}] + """), + objectMapper); + var policyCatalog = new EnvironmentMaskingPolicyCatalog( + new MaskingProperties(""" + [{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"}] + """), + objectMapper); var synchronizer = new MaskingPolicySynchronizer( - mock(JdbcTemplate.class), mock(MaskingRuleMapper.class), catalogProvider); + mock(JdbcTemplate.class), + mock(MaskingRuleMapper.class), + dataCatalog, + policyCatalog); assertThat(synchronizer.owner()).isEqualTo("ADMIN"); assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES")) diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 328c347..121914b 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -2,7 +2,10 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -10,16 +13,70 @@ import org.junit.jupiter.api.Test; class McpSseServiceTest { + private static final String HMM_TOOLS = """ + [ + { + "name":"resolve_hr_term", + "label":"HMM HR 용어 표준화", + "description":"휴가·근태 표현을 표준 용어와 코드로 변환합니다.", + "argumentName":"term", + "argumentDescription":"확인할 휴가·근태 용어입니다.", + "executionType":"AGENT_TOOL", + "targetName":"HMM_HR_TERM_RESOLVER", + "targetParameterName":"P_TERM" + }, + { + "name":"search_hr_data", + "label":"HMM HR 데이터 조회", + "description":"조직, 직원, 휴가, 근태 데이터를 조회합니다.", + "argumentName":"query", + "argumentDescription":"완전한 자연어 질문입니다.", + "executionType":"AGENT_TOOL", + "targetName":"HMM_HR_NORMALIZED_DATA_SEARCH", + "targetParameterName":"P_QUERY" + }, + { + "name":"search_hr_policy", + "label":"HMM HR 규정 검색", + "description":"HR 규정 PDF를 검색합니다.", + "argumentName":"query", + "argumentDescription":"정책에 대한 자연어 질문입니다.", + "executionType":"AGENT_TOOL", + "targetName":"HMM_HR_POLICY_SEARCH", + "targetParameterName":"P_QUERY" + } + ] + """; + private final ObjectMapper objectMapper = new ObjectMapper(); - private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner(); + private final McpProperties mcpProperties = + new McpProperties( + "https://example.com/mcp", + "hmm-hr-backoffice", + "", + "", + "", + "", + HMM_TOOLS); + private final McpToolCatalog toolCatalog = + new EnvironmentMcpToolCatalog(mcpProperties, objectMapper); + private final CapturingHmmAiAgentToolRunner agentToolRunner = + new CapturingHmmAiAgentToolRunner(); private final HmmMcpBearerAuthenticator bearerAuthenticator = token -> new HmmMcpPrincipal(1L, "E1001", 1L); - private final McpSseService service = - new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper); + private final McpSseService service = new McpSseService( + agentToolRunner, + mock(SelectAiService.class), + bearerAuthenticator, + toolCatalog, + mcpProperties, + new BackofficeProperties(null, null, null, null, null), + objectMapper); @Test - void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() { - ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token"); + void listsEnvironmentConfiguredTermDataAndPolicyTools() { + ObjectNode response = + service.handle("default", request(1, "tools/list"), "valid-token"); var tools = response.path("result").path("tools"); assertThat(tools).hasSize(3); @@ -31,13 +88,10 @@ class McpSseServiceTest { assertThat(tools.get(1).path("inputSchema").path("required")) .extracting(JsonNode::asText) .containsExactly("query"); - assertThat(tools.get(2).path("inputSchema").path("required")) - .extracting(JsonNode::asText) - .containsExactly("query"); } @Test - void callsHMMTermResolverWithTheApprovedAgentToolAndInputName() { + void callsConfiguredAgentToolWithItsDeclaredInputName() { ObjectNode request = request(2, "tools/call"); ObjectNode params = request.putObject("params"); params.put("name", "resolve_hr_term"); @@ -50,19 +104,19 @@ class McpSseServiceTest { assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); - assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("resolve_hr_term") - .contains("HMM_HR_TERM_RESOLVER") - .contains("ANNUAL_LEAVE_CARRYOVER"); } @Test void rejectsDiscoveryWhenBearerAuthenticationFails() { McpSseService rejectingService = new McpSseService( agentToolRunner, + mock(SelectAiService.class), token -> { throw new McpUnauthorizedException(); }, + toolCatalog, + mcpProperties, + new BackofficeProperties(null, null, null, null, null), objectMapper); assertThatThrownBy(() -> @@ -71,16 +125,38 @@ class McpSseServiceTest { } @Test - void rejectsUnknownToolsWithoutCallingTheAgentRunner() { + void rejectsUnknownToolsWithoutCallingTheRunner() { ObjectNode request = request(3, "tools/call"); ObjectNode params = request.putObject("params"); - params.put("name", "ords.query.kb_select_ai_vpd"); - params.putObject("arguments").put("prompt", "legacy query"); + params.put("name", "unconfigured.tool"); + params.putObject("arguments").put("prompt", "query"); ObjectNode response = service.handle("default", request, "valid-token"); assertThat(response.path("result").isMissingNode()).isTrue(); - assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool"); + assertThat(response.path("error").path("message").asText()) + .contains("등록되지 않은 MCP tool"); + } + + @Test + void rejectsDuplicateToolNamesAtStartup() { + String duplicate = """ + [ + {"name":"same","label":"A","description":"A","argumentName":"query", + "argumentDescription":"A","executionType":"AGENT_TOOL", + "targetName":"TOOL_A","targetParameterName":"P_QUERY"}, + {"name":"same","label":"B","description":"B","argumentName":"query", + "argumentDescription":"B","executionType":"AGENT_TOOL", + "targetName":"TOOL_B","targetParameterName":"P_QUERY"} + ] + """; + McpProperties duplicateProperties = + new McpProperties("", "", "", "", "", "", duplicate); + + assertThatThrownBy(() -> + new EnvironmentMcpToolCatalog(duplicateProperties, objectMapper)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("BACKOFFICE_MCP_TOOLS"); } private ObjectNode request(int id, String method) { @@ -91,7 +167,8 @@ class McpSseServiceTest { return request; } - private final class CapturingHmmAiAgentToolRunner implements HmmAiAgentToolRunner { + private final class CapturingHmmAiAgentToolRunner + implements HmmAiAgentToolRunner { private String toolName; private ObjectNode input; @@ -106,9 +183,7 @@ class McpSseServiceTest { toolName = requestedToolName; input = requestedInput.deepCopy(); this.bearerToken = bearerToken; - return objectMapper.createObjectNode() - .put("termCode", "ANNUAL_LEAVE_CARRYOVER") - .put("termName", "연차 이월"); + return objectMapper.createObjectNode().put("status", "ok"); } } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java new file mode 100644 index 0000000..962b798 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java @@ -0,0 +1,34 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.cloudhandson.vpdbackoffice.config.CatalogProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +class SchemaMetadataServiceTest { + + @Test + void rejectsAnnotationChangesForConfiguredViewsBeforeAnyDdl() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + DataCatalog catalog = new EnvironmentDataCatalog( + new CatalogProperties("ADMIN", """ + [{"key":"employee-view","tableName":"HMM_EMPLOYEE_VIEW","objectType":"VIEW", + "businessName":"직원 뷰","description":"읽기 전용 직원 뷰"}] + """), + new ObjectMapper()); + StructuredDataService structuredDataService = + new StructuredDataService(jdbcTemplate, catalog); + SchemaMetadataService service = + new SchemaMetadataService(jdbcTemplate, structuredDataService); + + assertThatThrownBy(() -> + service.updateTableAnnotation("employee-view", "DISPLAY_NAME", "직원")) + .isInstanceOf(AppException.class) + .hasMessageContaining("TABLE 객체에서만"); + verifyNoInteractions(jdbcTemplate); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java index 934b908..3d8c073 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java @@ -3,33 +3,51 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; class SecuritySqlScriptServiceTest { - private final SecuritySqlScriptService service = new SecuritySqlScriptService(); + private final SecuritySqlScriptService service = new SecuritySqlScriptService( + new SecuritySqlScriptProperties(""" + [{ + "scriptId":"hmm-leave-vpd", + "category":"HMM / VPD", + "fileName":"72_hmm_leave_team_vpd.sql", + "title":"HMM 휴가 팀 접근 정책", + "description":"팀장과 팀원 휴가 행 접근 정책" + }] + """), + new ObjectMapper()); @Test - void exposesOnlyTheCuratedGitTrackedSecurityScripts() { + void exposesOnlyEnvironmentAllowlistedBundledScripts() { assertThat(service.list()) .extracting(item -> item.fileName()) - .containsExactly( - "62_kb_aso_masking_backoffice_metadata.sql", - "63_kb_aso_masking_rule_runtime.sql", - "64_kb_aso_masking_default_column_rules.sql", - "65_kb_select_ai_vpd_query_api.sql", - "66_kb_select_ai_vpd_query_ords.sql" - ); + .containsExactly("72_hmm_leave_team_vpd.sql"); - assertThat(service.find("select-ai-vpd-ords").source()) - .contains("POST /ords/cb-ords/kb-select-ai-vpd/query") - .contains("Authorization: Bearer "); + assertThat(service.find("hmm-leave-vpd").source()) + .contains("HMM_ACCESS_CTX_PKG") + .contains("HMM_LEAVE_VPD_FILTER"); } @Test - void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() { + void rejectsUnknownScriptIdsInsteadOfResolvingARequestPath() { assertThatThrownBy(() -> service.find("../../etc/passwd")) .isInstanceOf(AppException.class) .hasMessageContaining("조회할 수 없는"); } + + @Test + void rejectsUnsafeResourcePathsAtStartup() { + assertThatThrownBy(() -> new SecuritySqlScriptService( + new SecuritySqlScriptProperties(""" + [{"scriptId":"bad","category":"x","fileName":"../../etc/passwd.sql", + "title":"x","description":"x"}] + """), + new ObjectMapper())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("BACKOFFICE_SECURITY_SQL_SCRIPTS"); + } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java new file mode 100644 index 0000000..7943b02 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java @@ -0,0 +1,35 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +class SelectAiServiceTest { + + private final SelectAiService service = new SelectAiService( + new BackofficeProperties(null, null, null, null, null), + token -> new HmmMcpPrincipal(1L, "E1001", 1L), + new ObjectMapper()); + + @Test + void acceptsOneReadOnlySelectAndRemovesTrailingSemicolon() { + assertThat(service.validateReadOnlySql( + "SELECT employee_code FROM hmm_hr_employees;")) + .isEqualTo("SELECT employee_code FROM hmm_hr_employees"); + } + + @Test + void rejectsDmlPackagesCommentsAndMultipleStatements() { + assertThatThrownBy(() -> + service.validateReadOnlySql("SELECT * FROM x; DELETE FROM x")) + .isInstanceOf(AppException.class); + assertThatThrownBy(() -> + service.validateReadOnlySql("SELECT DBMS_LOCK.SLEEP(10) FROM dual")) + .isInstanceOf(AppException.class); + assertThatThrownBy(() -> + service.validateReadOnlySql("SELECT * FROM x -- bypass")) + .isInstanceOf(AppException.class); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java index 8dd669a..afa00bf 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java @@ -4,61 +4,72 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import com.cloudhandson.vpdbackoffice.config.CatalogProperties; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; class StructuredDataServiceTest { - private final StructuredDataCatalogProvider catalogProvider = new StructuredDataCatalogProvider( - new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json")); - private final StructuredDataService service = new StructuredDataService( - mock(JdbcTemplate.class), catalogProvider); + private static final String HMM_OBJECTS = """ + [ + {"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE", + "businessName":"조직 원장","description":"조직 정보"}, + {"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE", + "businessName":"직원 원장","description":"직원 정보"}, + {"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"VIEW", + "businessName":"휴가 잔여 원장","description":"휴가 잔여 정보"} + ] + """; + + private final DataCatalog catalog = new EnvironmentDataCatalog( + new CatalogProperties("ADMIN", HMM_OBJECTS), new ObjectMapper()); + private final StructuredDataService service = + new StructuredDataService(mock(JdbcTemplate.class), catalog); @Test - void exposesOnlyTheHMMTablesFromTheJsonCatalog() { - assertThat(service.catalog().sourceName()).isEqualTo("HMMAIPOC"); + void exposesOnlyEnvironmentConfiguredTablesAndViews() { assertThat(service.owner()).isEqualTo("ADMIN"); assertThat(service.tables()) .extracting(table -> table.tableName()) .containsExactly( "HMM_ORG_TEAMS", "HMM_HR_EMPLOYEES", - "HMM_LEAVE_BALANCES", - "HMM_LEAVE_REQUESTS", - "HMM_ATTENDANCE_DAILY", - "HMM_HR_TERMS"); + "HMM_LEAVE_BALANCES"); + assertThat(service.requireTable("leave-balances").objectType()).isEqualTo("VIEW"); assertThat(service.previewSql(service.requireTable("employees"))) .isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?"); } @Test - void rejectsAnyTableOutsideTheServerSideAllowlist() { + void rejectsAnyObjectOutsideTheServerSideAllowlist() { assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG")) .isInstanceOf(AppException.class) - .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); + .hasMessage("선택할 수 없는 카탈로그 객체입니다."); } @Test - void rejectsUnsafeOracleIdentifiersInAnExternalCatalog() { - String unsafeJson = """ - { - "sourceName": "demo", - "owner": "ADMIN; DROP USER X", - "pageHelp": "help", - "catalogDescription": "description", - "rowLimit": 10, - "tables": [ - {"key":"employees","tableName":"EMPLOYEES","businessName":"직원","description":"직원"} - ] - } + void rejectsUnsafeOracleIdentifiers() { + assertThatThrownBy(() -> new EnvironmentDataCatalog( + new CatalogProperties("ADMIN; DROP USER X", HMM_OBJECTS), new ObjectMapper())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("BACKOFFICE_CATALOG_OWNER"); + } + + @Test + void rejectsDuplicateObjectNamesEvenWhenKeysDiffer() { + String duplicate = """ + [ + {"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE", + "businessName":"직원","description":"직원"}, + {"key":"workers","tableName":"HMM_HR_EMPLOYEES","objectType":"VIEW", + "businessName":"직원 뷰","description":"직원 뷰"} + ] """; - assertThatThrownBy(() -> new StructuredDataCatalogProvider( - new ObjectMapper(), new ByteArrayResource(unsafeJson.getBytes()))) + assertThatThrownBy(() -> new EnvironmentDataCatalog( + new CatalogProperties("ADMIN", duplicate), new ObjectMapper())) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("owner 형식이 올바르지 않습니다"); + .hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS"); } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index 074d021..303be54 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -35,7 +35,7 @@ class GuidedFlowTemplateTest { .doesNotContain("dashboard-workflow") .doesNotContain(">01<", ">02<"); assertThat(layout) - .contains("HMM HR Access Console") + .contains("Data & AI Backoffice") .contains("사용자") .contains("접근 그룹") .contains("역할") @@ -73,7 +73,7 @@ class GuidedFlowTemplateTest { void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException { assertThat(template("login.html")) .contains("name=\"remember-me\"") - .contains("데이터 접근 제어 콘솔") + .contains("product?.displayName()") .contains("접근") .contains("로그인 유지") .contains("th:if=\"${rememberMeAvailable}\"") @@ -265,11 +265,14 @@ class GuidedFlowTemplateTest { assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool"); assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path"); assertThat(sse) - .contains("Instruction / parameter mapping") - .contains("resolve_hr_term") - .contains("search_hr_data") - .contains("search_hr_policy") - .doesNotContain("kb_select_ai_vpd", "KB_CLAIMS"); + .contains("tools/list.inputSchema") + .contains("환경 설정으로 승인한 MCP 도구") + .doesNotContain( + "resolve_hr_term", + "search_hr_data", + "search_hr_policy", + "kb_select_ai_vpd", + "KB_CLAIMS"); } @Test diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java index 62fde34..e222bd8 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java @@ -30,7 +30,7 @@ class SettingsTemplateRenderTest { String database = engine.process("settings-database", context); assertThat(connection) - .contains("HMM HR Agent 도구") + .contains("설정 기반 MCP 도구") .contains("https://hmm-backoffice.cloud-handson.com/mcp") .contains("Legacy ORDS Base URL") .contains("/settings/database") From 65a2b27fccbd7a1fc8667442c86d318e314dd7b4 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 24 Jul 2026 14:04:42 +0900 Subject: [PATCH 13/18] refs #729: seed RDS carrier performance demo --- README.md | 4 + .../README.md | 139 ++++++++ .../postgres_hmm_carrier_performance.sql | 300 ++++++++++++++++ ...ostgres_hmm_carrier_performance_verify.sql | 324 ++++++++++++++++++ 4 files changed, 767 insertions(+) create mode 100644 docs/design/729-rds-carrier-performance-demo/README.md create mode 100644 sql/source/postgres_hmm_carrier_performance.sql create mode 100644 sql/source/postgres_hmm_carrier_performance_verify.sql diff --git a/README.md b/README.md index 62ea566..9d9b1af 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드, | VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 | | VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 | | VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 | +| 원격 원장 seed/검증 SQL | `sql/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 | | DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 | | Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot | | Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 | @@ -97,6 +98,7 @@ Git 기준은 다음과 같다. | 계층 | 객체 | 역할 | |---|---|---| | 원격 | `public.customers` (PG), `ecommerce_poc.customers` (MySQL) | 원본 데이터 | +| 원격 HMM 데모 | `hmm_demo.carriers`, `hmm_demo.carrier_monthly_performance` (PG) | 가상 선사 기준정보와 최근 18개월 월간 KPI | | ADB - 연결 | `RDS_POSTGRES_LINK`, `RDS_LINK` | `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 로 만든 heterogeneous DB Link | | ADB - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 | | ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 | @@ -261,6 +263,8 @@ CREATE DATA GRANT admin.alice_apac_grant ├── sql/ │ ├── source/ │ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows +│ │ ├── postgres_hmm_carrier_performance.sql # 원격 PG: HMM 가상 선사 8개 + 월간 KPI 144 rows +│ │ ├── postgres_hmm_carrier_performance_verify.sql # HMM 선사 실적 건수·패턴·제약·TLS·권한 검증 │ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows │ └── adb/ │ ├── 00_cleanup.sql # 멱등 teardown diff --git a/docs/design/729-rds-carrier-performance-demo/README.md b/docs/design/729-rds-carrier-performance-demo/README.md new file mode 100644 index 0000000..897c6a2 --- /dev/null +++ b/docs/design/729-rds-carrier-performance-demo/README.md @@ -0,0 +1,139 @@ +# HMM RDS PostgreSQL 가상 선사 실적 데이터 + +- Redmine: #729 +- 적용 브랜치: `hmm-backoffice` +- 원격 원장: AWS RDS for PostgreSQL +- 대상 스키마: `hmm_demo` +- 기준월 범위: 2025-02-01 ~ 2026-07-01 + +## 1. 배경 + +HMM 데모는 Oracle ADB의 직원·조직·권한 데이터와 AWS RDS PostgreSQL의 선사 실적을 +federation으로 결합한다. 사용자는 ADB에서 발급한 토큰으로 식별하고, 팀원은 자신에게 +배정된 선사만, 팀장은 팀원에게 배정된 선사 전체를 조회하는 흐름을 보여준다. + +이번 단계에서는 federation 연결 전에 RDS에 재현 가능한 가상 선사 실적 원장을 만든다. +실제 회사나 실제 재무 실적처럼 오인되지 않도록 모든 선사명과 수치는 데모 전용으로 +생성한다. + +## 2. 시스템별 책임 + +| 시스템 | 보관 정보 | 접근 제어 책임 | +|---|---|---| +| RDS PostgreSQL | 선사 기본정보, 선사별 월간 KPI | 전용 스키마와 조회 전용 역할 | +| Oracle ADB | 직원, 팀, 담당 선사 배정, 사용자 토큰 | VPD와 사용자 세션 context | +| Select AI/MCP | 자연어 질문, 보안 View 조회 | ADB에서 허용한 View만 사용 | + +직원 사번이나 팀 정보는 RDS에 복제하지 않는다. RDS 데이터에는 선사 코드만 두고, 직원과 +선사의 배정 관계는 후속 ADB 단계에서 `HMM_CARRIER_ASSIGNMENTS`로 관리한다. + +## 3. 데이터 모델 + +### 3.1 `hmm_demo.carriers` + +가상 선사 기준정보다. + +| 컬럼 | 형식 | 설명 | +|---|---|---| +| `carrier_code` | `varchar(10)` | 가상 선사 식별 코드, PK | +| `carrier_name` | `varchar(120)` | 가상 선사명, UNIQUE | +| `country_code` | `char(2)` | ISO 형식의 데모 국가 코드 | +| `alliance_name` | `varchar(80)` | 가상 협력 네트워크 | +| `service_region` | `varchar(40)` | 주력 운항 권역 | +| `performance_tier` | `varchar(10)` | `CORE`, `GROWTH`, `WATCH` | +| `active` | `boolean` | 사용 여부 | +| `created_at` | `timestamptz` | 최초 생성 시각 | + +### 3.2 `hmm_demo.carrier_monthly_performance` + +선사별 월간 실적 fact다. 한 선사·한 기준월에 한 행만 허용한다. + +| 컬럼 | 형식 | 설명 | +|---|---|---| +| `carrier_code` | `varchar(10)` | 선사 코드, FK | +| `performance_month` | `date` | 월의 첫째 날 | +| `contracted_teu` | `integer` | 계약 물동량 | +| `shipped_teu` | `integer` | 실제 운송 물동량 | +| `revenue_usd` | `numeric(16,2)` | 매출 | +| `gross_margin_usd` | `numeric(16,2)` | 매출총이익, 위험 패턴은 음수 허용 | +| `schedule_reliability_pct` | `numeric(5,2)` | 정시 운항률 | +| `vessel_utilization_pct` | `numeric(5,2)` | 선복 활용률 | +| `claim_rate_pct` | `numeric(5,2)` | 클레임 비율 | +| `yoy_growth_pct` | `numeric(7,2)` | 전년 동월 대비 성장률 | +| `risk_level` | `varchar(10)` | `GREEN`, `AMBER`, `RED` | +| `updated_at` | `timestamptz` | 적재 시각 | + +PK는 `(carrier_code, performance_month)`다. 퍼센트 값은 0~100, 물동량과 매출은 0 이상을 +CHECK 제약으로 보장한다. 월별 조회와 위험 선사 조회에 각각 인덱스를 둔다. + +### 3.3 `hmm_demo.carrier_performance_latest_v` + +가장 최근 기준월의 선사명과 KPI를 한 번에 조회하는 View다. 후속 federation 점검과 +백오피스 미리보기에서 사용한다. + +## 4. 가상 데이터 + +8개 가상 선사와 18개월 실적 144행을 만든다. + +| 코드 | 가상 선사 | 의도한 데모 패턴 | +|---|---|---| +| `C001` | Bluewave Maritime | 높은 정시율과 지속 성장 | +| `C002` | Pacific Star Lines | 안정적 실적 | +| `C003` | Korea Ocean Link | 정시율과 클레임률 회복 | +| `C004` | Nordic Seaways | 계절성 변동 | +| `C005` | Orient Bridge Shipping | 점진적 물동량·마진 하락 | +| `C006` | Atlantic Merchant Line | 정시율 저하와 클레임 증가 | +| `C007` | Global Horizon Cargo | 빠른 성장 | +| `C008` | Southern Cross Marine | 지속적인 고위험 패턴 | + +모든 수치는 프로파일 상수와 월 순번으로 계산한다. 난수를 사용하지 않으므로 어느 환경에서 +다시 실행해도 같은 결과가 만들어진다. + +## 5. 재실행과 변경 규칙 + +- `CREATE SCHEMA/TABLE IF NOT EXISTS`를 사용한다. +- 기준정보와 실적은 PK 기준 `ON CONFLICT DO UPDATE`로 현행화한다. +- 스크립트가 소유하지 않은 행은 삭제하지 않는다. +- 전체 실행은 하나의 transaction으로 처리한다. +- 오류가 발생하면 `ON_ERROR_STOP`과 transaction rollback으로 부분 적재를 방지한다. +- 비밀번호, endpoint, CA 파일 경로는 SQL에 넣지 않는다. + +## 6. 보안 + +- `hmm_federation_reader`는 `NOLOGIN` 그룹 역할로 만든다. +- 해당 역할에는 `hmm_demo`의 `USAGE`와 테이블/View `SELECT`만 부여한다. +- ADB database link용 LOGIN 역할과 비밀번호는 federation 단계에서 별도로 생성하고 + `hmm_federation_reader` 역할만 상속시킨다. +- DDL/DML 권한과 `public` 스키마 변경 권한은 federation 역할에 부여하지 않는다. +- RDS 연결은 AWS global CA bundle과 `sslmode=verify-full`로 검증한다. + +## 7. 적재 순서 + +1. RDS TLS와 현재 계정의 CREATE 권한을 확인한다. +2. `sql/source/postgres_hmm_carrier_performance.sql`을 실행한다. +3. `sql/source/postgres_hmm_carrier_performance_verify.sql`을 실행한다. +4. 재실행 후에도 동일한 선사 8개와 실적 144행인지 확인한다. +5. 후속 작업에서 ADB credential과 PostgreSQL database link를 만든다. + +## 8. 검증 기준 + +- 선사 수: `8` +- 월간 실적: `144` +- 선사별 월 수: 모두 `18` +- 기간: `2025-02-01` ~ `2026-07-01` +- 최신월 위험 분포에 `GREEN`, `AMBER`, `RED`가 모두 존재 +- `C001`의 최신 매출이 최초 매출보다 증가 +- `C005`, `C006`, `C008`의 최신 매출이 최초 매출보다 감소 +- 존재하지 않는 선사 코드, 잘못된 퍼센트, 월 첫째 날이 아닌 날짜 입력 거부 +- `hmm_federation_reader`에 `SELECT`만 부여 +- TLS 1.2 이상으로 실제 조회 성공 + +## 9. 제외 범위 + +- ADB의 `HMM_CARRIER_ASSIGNMENTS` 생성과 직원별 배정 +- ADB PostgreSQL database link 생성 +- ADB 보안 View와 VPD 정책 +- Select AI profile 및 MCP tool 변경 +- 실제 선사명이나 실제 경영 실적 적재 + +이 항목들은 RDS 가상 원장이 검증된 뒤 별도 단계로 진행한다. diff --git a/sql/source/postgres_hmm_carrier_performance.sql b/sql/source/postgres_hmm_carrier_performance.sql new file mode 100644 index 0000000..509895b --- /dev/null +++ b/sql/source/postgres_hmm_carrier_performance.sql @@ -0,0 +1,300 @@ +-- ============================================================ +-- HMM demo carrier performance source data for PostgreSQL. +-- +-- Target: AWS RDS for PostgreSQL, database postgres +-- Schema: hmm_demo +-- Redmine: #729 +-- +-- This script is idempotent. It owns only C001..C008 and their +-- 2025-02 through 2026-07 monthly demo rows. It never stores an +-- RDS endpoint, password, or certificate path. +-- +-- Example: +-- PGPASSWORD="$PG_PASSWORD" psql \ +-- "host=$PG_HOST port=5432 dbname=postgres user=$PG_USER \ +-- sslmode=verify-full sslrootcert=$PG_SSL_ROOT_CERT" \ +-- -X -f sql/source/postgres_hmm_carrier_performance.sql +-- ============================================================ + +\set ON_ERROR_STOP on +\echo === HMM carrier performance demo: begin === + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS hmm_demo AUTHORIZATION postgres; +COMMENT ON SCHEMA hmm_demo IS + 'HMM federation demonstration data. All carrier names and KPI values are fictional.'; + +REVOKE CREATE ON SCHEMA hmm_demo FROM PUBLIC; + +CREATE TABLE IF NOT EXISTS hmm_demo.carriers ( + carrier_code VARCHAR(10) PRIMARY KEY, + carrier_name VARCHAR(120) NOT NULL UNIQUE, + country_code CHAR(2) NOT NULL CHECK (country_code ~ '^[A-Z]{2}$'), + alliance_name VARCHAR(80), + service_region VARCHAR(40) NOT NULL, + performance_tier VARCHAR(10) NOT NULL + CHECK (performance_tier IN ('CORE', 'GROWTH', 'WATCH')), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE hmm_demo.carriers IS + 'Fictional carrier master data for the HMM ADB and PostgreSQL federation demo.'; +COMMENT ON COLUMN hmm_demo.carriers.carrier_code IS 'Fictional carrier identifier.'; +COMMENT ON COLUMN hmm_demo.carriers.performance_tier IS + 'Demo portfolio tier: CORE, GROWTH, or WATCH.'; + +CREATE TABLE IF NOT EXISTS hmm_demo.carrier_monthly_performance ( + carrier_code VARCHAR(10) NOT NULL + REFERENCES hmm_demo.carriers (carrier_code), + performance_month DATE NOT NULL + CHECK (performance_month = date_trunc('month', performance_month)::DATE), + contracted_teu INTEGER NOT NULL CHECK (contracted_teu >= 0), + shipped_teu INTEGER NOT NULL CHECK (shipped_teu >= 0), + revenue_usd NUMERIC(16,2) NOT NULL CHECK (revenue_usd >= 0), + gross_margin_usd NUMERIC(16,2) NOT NULL, + schedule_reliability_pct NUMERIC(5,2) NOT NULL + CHECK (schedule_reliability_pct BETWEEN 0 AND 100), + vessel_utilization_pct NUMERIC(5,2) NOT NULL + CHECK (vessel_utilization_pct BETWEEN 0 AND 100), + claim_rate_pct NUMERIC(5,2) NOT NULL + CHECK (claim_rate_pct BETWEEN 0 AND 100), + yoy_growth_pct NUMERIC(7,2) NOT NULL, + risk_level VARCHAR(10) NOT NULL + CHECK (risk_level IN ('GREEN', 'AMBER', 'RED')), + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (carrier_code, performance_month) +); + +COMMENT ON TABLE hmm_demo.carrier_monthly_performance IS + 'Deterministic fictional monthly carrier KPIs for 2025-02 through 2026-07.'; +COMMENT ON COLUMN hmm_demo.carrier_monthly_performance.performance_month IS + 'First calendar day of the KPI month.'; +COMMENT ON COLUMN hmm_demo.carrier_monthly_performance.risk_level IS + 'Derived demo risk level based on margin, reliability, and claim rate.'; + +CREATE INDEX IF NOT EXISTS ix_carrier_perf_month + ON hmm_demo.carrier_monthly_performance (performance_month, carrier_code); +CREATE INDEX IF NOT EXISTS ix_carrier_perf_risk + ON hmm_demo.carrier_monthly_performance (risk_level, performance_month); + +INSERT INTO hmm_demo.carriers ( + carrier_code, + carrier_name, + country_code, + alliance_name, + service_region, + performance_tier, + active +) VALUES + ('C001', 'Bluewave Maritime', 'SG', 'North Pacific Network', 'ASIA-PACIFIC', 'CORE', TRUE), + ('C002', 'Pacific Star Lines', 'JP', 'East Ocean Connect', 'ASIA-PACIFIC', 'CORE', TRUE), + ('C003', 'Korea Ocean Link', 'KR', 'East Ocean Connect', 'ASIA-PACIFIC', 'GROWTH', TRUE), + ('C004', 'Nordic Seaways', 'DK', 'Northern Trade Group', 'EUROPE', 'CORE', TRUE), + ('C005', 'Orient Bridge Shipping', 'TW', 'North Pacific Network', 'ASIA-PACIFIC', 'WATCH', TRUE), + ('C006', 'Atlantic Merchant Line', 'DE', 'Atlantic Cargo Forum', 'EUROPE-AMERICAS', 'WATCH', TRUE), + ('C007', 'Global Horizon Cargo', 'US', 'Atlantic Cargo Forum', 'AMERICAS', 'GROWTH', TRUE), + ('C008', 'Southern Cross Marine', 'FR', 'Southern Ocean Group', 'OCEANIA', 'WATCH', TRUE) +ON CONFLICT (carrier_code) DO UPDATE SET + carrier_name = EXCLUDED.carrier_name, + country_code = EXCLUDED.country_code, + alliance_name = EXCLUDED.alliance_name, + service_region = EXCLUDED.service_region, + performance_tier = EXCLUDED.performance_tier, + active = EXCLUDED.active; + +WITH month_series AS ( + SELECT + month_index, + (DATE '2025-02-01' + make_interval(months => month_index))::DATE AS performance_month, + CASE month_index % 6 + WHEN 0 THEN -1 + WHEN 1 THEN 0 + WHEN 2 THEN 1 + WHEN 3 THEN 2 + WHEN 4 THEN 1 + ELSE -1 + END::NUMERIC AS seasonal_factor + FROM generate_series(0, 17) AS series(month_index) +), +profiles ( + carrier_code, + base_teu, + monthly_teu_step, + seasonal_teu, + utilization_base, + utilization_step, + revenue_per_teu, + revenue_step, + margin_pct_base, + margin_pct_step, + reliability_base, + reliability_step, + claim_rate_base, + claim_rate_step, + yoy_growth_base, + yoy_growth_step +) AS ( + VALUES + ('C001', 1800::NUMERIC, 25::NUMERIC, 80::NUMERIC, 91.5::NUMERIC, 0.10::NUMERIC, 1850::NUMERIC, 4::NUMERIC, 15.0::NUMERIC, 0.10::NUMERIC, 95.0::NUMERIC, 0.05::NUMERIC, 0.35::NUMERIC, -0.005::NUMERIC, 8.0::NUMERIC, 0.25::NUMERIC), + ('C002', 1500::NUMERIC, 5::NUMERIC, 60::NUMERIC, 88.5::NUMERIC, 0.02::NUMERIC, 1780::NUMERIC, 3::NUMERIC, 12.0::NUMERIC, 0.00::NUMERIC, 92.5::NUMERIC, 0.00::NUMERIC, 0.45::NUMERIC, 0.005::NUMERIC, 3.0::NUMERIC, 0.05::NUMERIC), + ('C003', 1050::NUMERIC, 15::NUMERIC, 50::NUMERIC, 83.0::NUMERIC, 0.20::NUMERIC, 1920::NUMERIC, 2::NUMERIC, 8.0::NUMERIC, 0.15::NUMERIC, 88.5::NUMERIC, 0.25::NUMERIC, 1.20::NUMERIC, -0.040::NUMERIC, -4.0::NUMERIC, 0.60::NUMERIC), + ('C004', 1350::NUMERIC, 0::NUMERIC, 180::NUMERIC, 86.0::NUMERIC, 0.00::NUMERIC, 1810::NUMERIC, 3::NUMERIC, 10.0::NUMERIC, 0.00::NUMERIC, 93.0::NUMERIC, 0.00::NUMERIC, 0.55::NUMERIC, 0.010::NUMERIC, 1.0::NUMERIC, 0.10::NUMERIC), + ('C005', 1300::NUMERIC, -20::NUMERIC, 70::NUMERIC, 88.0::NUMERIC, -0.10::NUMERIC, 1850::NUMERIC, -2::NUMERIC, 8.0::NUMERIC, -0.20::NUMERIC, 91.0::NUMERIC, -0.15::NUMERIC, 0.80::NUMERIC, 0.030::NUMERIC, -2.0::NUMERIC, -0.50::NUMERIC), + ('C006', 1250::NUMERIC, -10::NUMERIC, 90::NUMERIC, 84.0::NUMERIC, -0.12::NUMERIC, 1800::NUMERIC, -3::NUMERIC, 6.0::NUMERIC, -0.15::NUMERIC, 87.0::NUMERIC, -0.25::NUMERIC, 1.50::NUMERIC, 0.050::NUMERIC, -5.0::NUMERIC, -0.70::NUMERIC), + ('C007', 800::NUMERIC, 30::NUMERIC, 100::NUMERIC, 82.0::NUMERIC, 0.25::NUMERIC, 2050::NUMERIC, 5::NUMERIC, 9.0::NUMERIC, 0.18::NUMERIC, 90.5::NUMERIC, 0.15::NUMERIC, 0.70::NUMERIC, -0.020::NUMERIC, 12.0::NUMERIC, 0.50::NUMERIC), + ('C008', 1150::NUMERIC, -25::NUMERIC, 60::NUMERIC, 80.0::NUMERIC, -0.15::NUMERIC, 1750::NUMERIC, -4::NUMERIC, 3.0::NUMERIC, -0.20::NUMERIC, 85.0::NUMERIC, -0.30::NUMERIC, 2.00::NUMERIC, 0.080::NUMERIC, -8.0::NUMERIC, -0.80::NUMERIC) +), +base_metrics AS ( + SELECT + p.carrier_code, + m.performance_month, + ROUND( + p.base_teu + + p.monthly_teu_step * m.month_index + + p.seasonal_teu * m.seasonal_factor + )::INTEGER AS contracted_teu, + LEAST( + 98.0::NUMERIC, + GREATEST( + 60.0::NUMERIC, + p.utilization_base + + p.utilization_step * m.month_index + + m.seasonal_factor * 0.50 + ) + ) AS utilization_pct, + p.revenue_per_teu + p.revenue_step * m.month_index AS unit_revenue, + p.margin_pct_base + p.margin_pct_step * m.month_index AS margin_pct, + LEAST( + 99.5::NUMERIC, + GREATEST( + 60.0::NUMERIC, + p.reliability_base + + p.reliability_step * m.month_index + + m.seasonal_factor * 0.20 + ) + ) AS reliability_pct, + LEAST( + 100.0::NUMERIC, + GREATEST( + 0.0::NUMERIC, + p.claim_rate_base + p.claim_rate_step * m.month_index + ) + ) AS claim_pct, + p.yoy_growth_base + p.yoy_growth_step * m.month_index AS yoy_pct + FROM profiles p + CROSS JOIN month_series m +), +volume_metrics AS ( + SELECT + b.*, + ROUND(b.contracted_teu * b.utilization_pct / 100.0)::INTEGER AS shipped_teu + FROM base_metrics b +), +financial_metrics AS ( + SELECT + v.*, + ROUND(v.shipped_teu * v.unit_revenue, 2) AS revenue_usd + FROM volume_metrics v +), +final_metrics AS ( + SELECT + f.*, + ROUND(f.revenue_usd * f.margin_pct / 100.0, 2) AS gross_margin_usd + FROM financial_metrics f +) +INSERT INTO hmm_demo.carrier_monthly_performance ( + carrier_code, + performance_month, + contracted_teu, + shipped_teu, + revenue_usd, + gross_margin_usd, + schedule_reliability_pct, + vessel_utilization_pct, + claim_rate_pct, + yoy_growth_pct, + risk_level, + updated_at +) +SELECT + f.carrier_code, + f.performance_month, + f.contracted_teu, + f.shipped_teu, + f.revenue_usd, + f.gross_margin_usd, + ROUND(f.reliability_pct, 2), + ROUND(f.utilization_pct, 2), + ROUND(f.claim_pct, 2), + ROUND(f.yoy_pct, 2), + CASE + WHEN f.gross_margin_usd < 0 + OR f.reliability_pct < 87 + OR f.claim_pct >= 2 THEN 'RED' + WHEN f.margin_pct < 7 + OR f.reliability_pct < 92 + OR f.claim_pct >= 1 THEN 'AMBER' + ELSE 'GREEN' + END, + TIMESTAMPTZ '2026-07-24 00:00:00+00' +FROM final_metrics f +ON CONFLICT (carrier_code, performance_month) DO UPDATE SET + contracted_teu = EXCLUDED.contracted_teu, + shipped_teu = EXCLUDED.shipped_teu, + revenue_usd = EXCLUDED.revenue_usd, + gross_margin_usd = EXCLUDED.gross_margin_usd, + schedule_reliability_pct = EXCLUDED.schedule_reliability_pct, + vessel_utilization_pct = EXCLUDED.vessel_utilization_pct, + claim_rate_pct = EXCLUDED.claim_rate_pct, + yoy_growth_pct = EXCLUDED.yoy_growth_pct, + risk_level = EXCLUDED.risk_level, + updated_at = EXCLUDED.updated_at; + +CREATE OR REPLACE VIEW hmm_demo.carrier_performance_latest_v AS +SELECT + c.carrier_code, + c.carrier_name, + c.country_code, + c.alliance_name, + c.service_region, + c.performance_tier, + p.performance_month, + p.contracted_teu, + p.shipped_teu, + p.revenue_usd, + p.gross_margin_usd, + p.schedule_reliability_pct, + p.vessel_utilization_pct, + p.claim_rate_pct, + p.yoy_growth_pct, + p.risk_level +FROM hmm_demo.carriers c +JOIN hmm_demo.carrier_monthly_performance p + ON p.carrier_code = c.carrier_code +WHERE p.performance_month = ( + SELECT MAX(latest.performance_month) + FROM hmm_demo.carrier_monthly_performance latest +); + +COMMENT ON VIEW hmm_demo.carrier_performance_latest_v IS + 'Latest fictional monthly KPI for each active demo carrier.'; + +DO $role$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hmm_federation_reader') THEN + CREATE ROLE hmm_federation_reader NOLOGIN; + END IF; +END +$role$; + +GRANT USAGE ON SCHEMA hmm_demo TO hmm_federation_reader; +GRANT SELECT ON ALL TABLES IN SCHEMA hmm_demo TO hmm_federation_reader; +ALTER DEFAULT PRIVILEGES IN SCHEMA hmm_demo + GRANT SELECT ON TABLES TO hmm_federation_reader; + +COMMIT; + +\echo === HMM carrier performance demo: complete === +\echo Next: run sql/source/postgres_hmm_carrier_performance_verify.sql diff --git a/sql/source/postgres_hmm_carrier_performance_verify.sql b/sql/source/postgres_hmm_carrier_performance_verify.sql new file mode 100644 index 0000000..8d5b4f8 --- /dev/null +++ b/sql/source/postgres_hmm_carrier_performance_verify.sql @@ -0,0 +1,324 @@ +-- ============================================================ +-- Verification for HMM demo carrier performance data. +-- Redmine: #729 +-- ============================================================ + +\set ON_ERROR_STOP on +\pset pager off +\echo === HMM carrier performance demo: verification begin === + +DO $verify$ +DECLARE + carrier_count INTEGER; + performance_count INTEGER; + invalid_month_count INTEGER; + latest_month DATE; + earliest_month DATE; + wrong_series_count INTEGER; + latest_risk_count INTEGER; + wrong_trend_count INTEGER; + reader_can_login BOOLEAN; +BEGIN + SELECT COUNT(*) + INTO carrier_count + FROM hmm_demo.carriers + WHERE carrier_code BETWEEN 'C001' AND 'C008'; + + SELECT COUNT(*), MIN(performance_month), MAX(performance_month) + INTO performance_count, earliest_month, latest_month + FROM hmm_demo.carrier_monthly_performance + WHERE carrier_code BETWEEN 'C001' AND 'C008' + AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01'; + + SELECT COUNT(*) + INTO invalid_month_count + FROM hmm_demo.carrier_monthly_performance + WHERE performance_month <> date_trunc('month', performance_month)::DATE; + + SELECT COUNT(*) + INTO wrong_series_count + FROM ( + SELECT carrier_code, COUNT(*) AS month_count + FROM hmm_demo.carrier_monthly_performance + WHERE carrier_code BETWEEN 'C001' AND 'C008' + AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01' + GROUP BY carrier_code + HAVING COUNT(*) <> 18 + ) wrong_series; + + SELECT COUNT(DISTINCT risk_level) + INTO latest_risk_count + FROM hmm_demo.carrier_performance_latest_v; + + WITH boundaries AS ( + SELECT + carrier_code, + MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2025-02-01') + AS first_revenue, + MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2026-07-01') + AS latest_revenue + FROM hmm_demo.carrier_monthly_performance + WHERE carrier_code BETWEEN 'C001' AND 'C008' + GROUP BY carrier_code + ) + SELECT COUNT(*) + INTO wrong_trend_count + FROM boundaries + WHERE (carrier_code IN ('C001', 'C002', 'C003', 'C004', 'C007') + AND latest_revenue <= first_revenue) + OR (carrier_code IN ('C005', 'C006', 'C008') + AND latest_revenue >= first_revenue); + + SELECT rolcanlogin + INTO reader_can_login + FROM pg_roles + WHERE rolname = 'hmm_federation_reader'; + + IF carrier_count <> 8 THEN + RAISE EXCEPTION 'Expected 8 carriers, found %', carrier_count; + END IF; + IF performance_count <> 144 THEN + RAISE EXCEPTION 'Expected 144 monthly performance rows, found %', performance_count; + END IF; + IF earliest_month <> DATE '2025-02-01' OR latest_month <> DATE '2026-07-01' THEN + RAISE EXCEPTION 'Unexpected period: % through %', earliest_month, latest_month; + END IF; + IF invalid_month_count <> 0 OR wrong_series_count <> 0 THEN + RAISE EXCEPTION + 'Invalid monthly series: invalid dates %, wrong carrier series %', + invalid_month_count, + wrong_series_count; + END IF; + IF latest_risk_count <> 3 THEN + RAISE EXCEPTION 'Expected GREEN, AMBER, RED latest risks, found % levels', + latest_risk_count; + END IF; + IF wrong_trend_count <> 0 THEN + RAISE EXCEPTION 'Unexpected first-to-latest trend for % carriers', wrong_trend_count; + END IF; + IF reader_can_login IS DISTINCT FROM FALSE THEN + RAISE EXCEPTION 'hmm_federation_reader must remain a NOLOGIN role'; + END IF; + + IF NOT has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'USAGE') THEN + RAISE EXCEPTION 'hmm_federation_reader lacks schema USAGE'; + END IF; + IF has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'CREATE') THEN + RAISE EXCEPTION 'hmm_federation_reader unexpectedly has schema CREATE'; + END IF; + IF NOT has_table_privilege( + 'hmm_federation_reader', + 'hmm_demo.carrier_monthly_performance', + 'SELECT' + ) THEN + RAISE EXCEPTION 'hmm_federation_reader lacks SELECT'; + END IF; + IF has_table_privilege( + 'hmm_federation_reader', + 'hmm_demo.carrier_monthly_performance', + 'INSERT' + ) THEN + RAISE EXCEPTION 'hmm_federation_reader unexpectedly has INSERT'; + END IF; +END +$verify$; + +DO $constraint_tests$ +BEGIN + BEGIN + INSERT INTO hmm_demo.carrier_monthly_performance ( + carrier_code, + performance_month, + contracted_teu, + shipped_teu, + revenue_usd, + gross_margin_usd, + schedule_reliability_pct, + vessel_utilization_pct, + claim_rate_pct, + yoy_growth_pct, + risk_level, + updated_at + ) VALUES ( + 'C001', + DATE '2026-07-02', + 1, + 1, + 1, + 1, + 90, + 90, + 1, + 1, + 'GREEN', + CURRENT_TIMESTAMP + ); + RAISE EXCEPTION 'Month-first-day constraint did not reject invalid input'; + EXCEPTION + WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO hmm_demo.carrier_monthly_performance ( + carrier_code, + performance_month, + contracted_teu, + shipped_teu, + revenue_usd, + gross_margin_usd, + schedule_reliability_pct, + vessel_utilization_pct, + claim_rate_pct, + yoy_growth_pct, + risk_level, + updated_at + ) VALUES ( + 'UNKNOWN', + DATE '2026-08-01', + 1, + 1, + 1, + 1, + 90, + 90, + 1, + 1, + 'GREEN', + CURRENT_TIMESTAMP + ); + RAISE EXCEPTION 'Foreign key constraint did not reject unknown carrier'; + EXCEPTION + WHEN foreign_key_violation THEN + NULL; + END; + + BEGIN + INSERT INTO hmm_demo.carrier_monthly_performance ( + carrier_code, + performance_month, + contracted_teu, + shipped_teu, + revenue_usd, + gross_margin_usd, + schedule_reliability_pct, + vessel_utilization_pct, + claim_rate_pct, + yoy_growth_pct, + risk_level, + updated_at + ) VALUES ( + 'C001', + DATE '2026-08-01', + 1, + 1, + 1, + 1, + 101, + 90, + 1, + 1, + 'GREEN', + CURRENT_TIMESTAMP + ); + RAISE EXCEPTION 'Percentage constraint did not reject invalid input'; + EXCEPTION + WHEN check_violation THEN + NULL; + END; +END +$constraint_tests$; + +\echo --- Row counts and period --- +SELECT + COUNT(DISTINCT carrier_code) AS carrier_count, + COUNT(*) AS monthly_row_count, + MIN(performance_month) AS first_month, + MAX(performance_month) AS latest_month +FROM hmm_demo.carrier_monthly_performance +WHERE carrier_code BETWEEN 'C001' AND 'C008' + AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01'; + +\echo --- Per-carrier series --- +SELECT + c.carrier_code, + c.carrier_name, + COUNT(p.*) AS month_count, + MIN(p.performance_month) AS first_month, + MAX(p.performance_month) AS latest_month +FROM hmm_demo.carriers c +JOIN hmm_demo.carrier_monthly_performance p + ON p.carrier_code = c.carrier_code +WHERE c.carrier_code BETWEEN 'C001' AND 'C008' +GROUP BY c.carrier_code, c.carrier_name +ORDER BY c.carrier_code; + +\echo --- Latest KPI and risk distribution --- +SELECT + carrier_code, + carrier_name, + performance_month, + shipped_teu, + revenue_usd, + gross_margin_usd, + schedule_reliability_pct, + claim_rate_pct, + risk_level +FROM hmm_demo.carrier_performance_latest_v +ORDER BY carrier_code; + +SELECT risk_level, COUNT(*) AS carrier_count +FROM hmm_demo.carrier_performance_latest_v +GROUP BY risk_level +ORDER BY risk_level; + +\echo --- First-to-latest revenue trend --- +WITH boundaries AS ( + SELECT + carrier_code, + MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2025-02-01') AS first_revenue, + MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2026-07-01') AS latest_revenue + FROM hmm_demo.carrier_monthly_performance + WHERE carrier_code BETWEEN 'C001' AND 'C008' + GROUP BY carrier_code +) +SELECT + carrier_code, + first_revenue, + latest_revenue, + CASE + WHEN latest_revenue > first_revenue THEN 'UP' + WHEN latest_revenue < first_revenue THEN 'DOWN' + ELSE 'FLAT' + END AS trend +FROM boundaries +ORDER BY carrier_code; + +\echo --- TLS session --- +SELECT + ssl, + version AS tls_version, + cipher, + bits +FROM pg_stat_ssl +WHERE pid = pg_backend_pid(); + +\echo --- Read-only role privileges --- +SELECT + (SELECT NOT rolcanlogin + FROM pg_roles + WHERE rolname = 'hmm_federation_reader') AS no_login, + has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'USAGE') AS schema_usage, + has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'CREATE') AS schema_create, + has_table_privilege( + 'hmm_federation_reader', + 'hmm_demo.carrier_monthly_performance', + 'SELECT' + ) AS can_select, + has_table_privilege( + 'hmm_federation_reader', + 'hmm_demo.carrier_monthly_performance', + 'INSERT' + ) AS can_insert; + +\echo === HMM carrier performance demo: verification complete === From cdb2c37ae30b0d7d209941074b40362fcc2f4b87 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 24 Jul 2026 14:18:19 +0900 Subject: [PATCH 14/18] refs #730: add ADB PostgreSQL federation recording scripts --- .../README.md | 137 ++++++++++++++++++ sql/adb/73_hmm_rds_pg_00_precheck.sql | 32 ++++ sql/adb/74_hmm_rds_pg_01_credential.sql | 20 +++ sql/adb/75_hmm_rds_pg_02_acl.sql | 61 ++++++++ sql/adb/76_hmm_rds_pg_03_dblink.sql | 24 +++ sql/adb/77_hmm_rds_pg_04_remote_query.sql | 34 +++++ sql/adb/78_hmm_rds_pg_05_views.sql | 109 ++++++++++++++ sql/adb/79_hmm_rds_pg_06_verify.sql | 86 +++++++++++ sql/adb/80_hmm_rds_pg_99_cleanup.sql | 63 ++++++++ 9 files changed, 566 insertions(+) create mode 100644 docs/design/730-adb-rds-postgres-dblink-demo/README.md create mode 100644 sql/adb/73_hmm_rds_pg_00_precheck.sql create mode 100644 sql/adb/74_hmm_rds_pg_01_credential.sql create mode 100644 sql/adb/75_hmm_rds_pg_02_acl.sql create mode 100644 sql/adb/76_hmm_rds_pg_03_dblink.sql create mode 100644 sql/adb/77_hmm_rds_pg_04_remote_query.sql create mode 100644 sql/adb/78_hmm_rds_pg_05_views.sql create mode 100644 sql/adb/79_hmm_rds_pg_06_verify.sql create mode 100644 sql/adb/80_hmm_rds_pg_99_cleanup.sql diff --git a/docs/design/730-adb-rds-postgres-dblink-demo/README.md b/docs/design/730-adb-rds-postgres-dblink-demo/README.md new file mode 100644 index 0000000..08a2173 --- /dev/null +++ b/docs/design/730-adb-rds-postgres-dblink-demo/README.md @@ -0,0 +1,137 @@ +# HMM ADB-RDS PostgreSQL DB Link 녹화 절차 + +- Redmine: #730 +- 적용 브랜치: `hmm-backoffice` +- 실행 위치: Oracle ADB Database Actions SQL Worksheet +- 실행 사용자: `ADMIN` +- 원격 DB: AWS RDS for PostgreSQL +- 원격 데이터베이스/스키마: `postgres` / `hmm_demo` + +## 1. 목적 + +ADB SQL Worksheet에서 AWS RDS PostgreSQL을 Database Link로 연결하고, HMM 가상 선사 +실적을 Oracle 로컬 View로 노출하는 전 과정을 녹화한다. + +녹화 순서는 다음과 같이 고정한다. + +1. 사전 점검 +2. PostgreSQL 접속 Credential 생성 +3. 대상 호스트 ACL 등록과 조회 +4. Oracle 관리형 PostgreSQL Database Link 생성 +5. 원격 테이블 직접 조회 +6. Select AI에서 사용할 로컬 View와 메타데이터 생성 +7. 행 수와 최신 실적 검증 + +## 2. 기준 자료 + +사내 교육자료를 실행 구문의 1차 기준으로 사용한다. + +- `/Users/joungminko/devkit/fy26_ai/fy26_ai_internal_training/2회차/proxy_database/README.md` +- PostgreSQL 절차: `3.2 PostgreSQL Database 연결` +- 검증된 주의사항: + - `gateway_params`의 `db_type`은 소문자 `postgres` + - `port`는 문자열이 아닌 숫자 `5432` + - PostgreSQL 스키마, 테이블, 컬럼 이름은 큰따옴표로 감싼다. + - Select AI에는 원격 테이블을 직접 등록하지 않고 메타데이터를 부여한 Oracle View를 + 등록한다. + +## 3. 연결값과 객체명 + +| 항목 | 값 | +|---|---| +| RDS host | `database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com` | +| RDS port | `5432` | +| PostgreSQL database | `postgres` | +| PostgreSQL user | `postgres` | +| ADB Credential | `HMM_RDS_PG_CRED` | +| ADB Database Link | `HMM_RDS_PG_LINK` | +| 원격 schema | `hmm_demo` | +| 로컬 carrier View | `HMM_RDS_CARRIERS_V` | +| 로컬 월간 실적 View | `HMM_RDS_CARRIER_PERF_V` | +| 로컬 최신 실적 View | `HMM_RDS_CARRIER_LATEST_V` | + +비밀번호는 SQL 파일에 저장하지 않는다. 녹화 직전 Worksheet에 붙여 넣은 Credential +구문의 ``만 실제 값으로 바꾸고, 비밀번호가 보이는 화면은 녹화에서 +가리거나 일시 정지한다. + +운영 전환 시에는 PostgreSQL master 사용자 대신 `hmm_federation_reader`만 상속한 전용 +LOGIN 사용자를 만들어 Credential을 교체한다. + +## 4. ACL의 역할 + +사내 교육자료의 Oracle 관리형 heterogeneous Database Link 절차에는 +`DBMS_NETWORK_ACL_ADMIN` 호출이 포함되지 않는다. DB Link 연결은 +`DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK`의 관리형 gateway가 수행한다. + +이번 데모의 ACL 스크립트는 요청한 네트워크 통제 절차를 명시적으로 보여주고, `ADMIN`에게 +RDS host의 이름 해석과 5432 포트 연결 권한이 등록되었음을 `DBA_HOST_ACES`로 확인하기 +위한 단계다. 이 ACL을 AWS RDS Security Group 허용이나 관리형 gateway의 출발 IP 허용과 +동일한 것으로 설명하지 않는다. + +- `resolve` ACE: 포트 범위를 지정하지 않는다. +- `connect` ACE: `5432`만 지정한다. +- AWS 측에서는 RDS가 public access 가능해야 하고, Security Group이 Oracle 관리형 + gateway에서 오는 접속을 허용해야 한다. +- 로컬 `global-bundle.pem`은 `psql` 검증용이다. 관리형 DB Link 생성 구문에 업로드하거나 + `directory_name`으로 지정하지 않는다. + +## 5. View 설계 + +### 5.1 `HMM_RDS_CARRIERS_V` + +`hmm_demo.carriers`의 Select AI용 기준정보 View다. PostgreSQL `boolean`과 +`timestamptz` 컬럼은 이번 분석 범위에서 제외해 이기종 타입 변환 변수를 줄인다. + +### 5.2 `HMM_RDS_CARRIER_PERF_V` + +`hmm_demo.carrier_monthly_performance`의 18개월 KPI 144행을 제공한다. 복합 Primary Key는 +`CARRIER_CODE + PERFORMANCE_MONTH`이며 `CARRIER_CODE`는 carrier View를 참조한다는 관계를 +View comment에 기록한다. + +### 5.3 `HMM_RDS_CARRIER_LATEST_V` + +원격 `hmm_demo.carrier_performance_latest_v`를 노출해 2026-07 최신월 선사 8건을 제공한다. + +각 View는 원격 소문자 컬럼을 Oracle의 일반 대문자 식별자로 명시적으로 alias한다. 따라서 +후속 SQL과 Select AI metadata에서는 큰따옴표 없이 안정적으로 사용할 수 있다. + +## 6. 녹화용 실행 파일 + +| 순서 | 파일 | 화면에서 확인할 결과 | +|---|---|---| +| 0 | `73_hmm_rds_pg_00_precheck.sql` | 현재 사용자 `ADMIN`, 기존 객체 유무 | +| 1 | `74_hmm_rds_pg_01_credential.sql` | Credential 1건, 사용자명 `postgres` | +| 2 | `75_hmm_rds_pg_02_acl.sql` | `resolve`, `connect:5432` ACE | +| 3 | `76_hmm_rds_pg_03_dblink.sql` | `HMM_RDS_PG_LINK` 1건 | +| 4 | `77_hmm_rds_pg_04_remote_query.sql` | 선사 8건, 월간 실적 144건 | +| 5 | `78_hmm_rds_pg_05_views.sql` | Oracle 로컬 View 3개 | +| 6 | `79_hmm_rds_pg_06_verify.sql` | `8 / 144 / 8`, 위험도 3종 | +| 재촬영 전 | `80_hmm_rds_pg_99_cleanup.sql` | View, DB Link, Credential만 제거 | + +## 7. 성공 기준 + +- `ALL_CREDENTIALS`에서 `HMM_RDS_PG_CRED`가 조회된다. +- `DBA_HOST_ACES`에서 RDS host의 `resolve`, `connect`가 조회된다. +- `USER_DB_LINKS`에서 `HMM_RDS_PG_LINK`가 조회된다. +- 원격 직접 조회가 선사 8건과 실적 144건을 반환한다. +- 로컬 View 3개가 `VALID` 상태다. +- 최신 View가 8건을 반환하고 `GREEN`, `AMBER`, `RED`가 모두 존재한다. +- Credential password가 SQL 파일, Git diff, 화면 출력에 남지 않는다. + +## 8. 실패 시 판별 + +| 증상 | 우선 확인 | +|---|---| +| `ORA-01031` | `ADMIN`으로 실행했는지 확인 | +| Credential already exists | 재촬영 전 cleanup 실행 여부 확인 | +| Database link already exists | 재촬영 전 cleanup 실행 여부 확인 | +| `ORA-28500`, `ORA-02063`, timeout | RDS 상태, public access, Security Group, endpoint/port 확인 | +| relation does not exist | `"hmm_demo"."..."` 큰따옴표와 객체명 확인 | +| 인증 실패 | Credential의 PostgreSQL 사용자/비밀번호 확인 | +| View comment 대상 오류 | 로컬 View/컬럼은 큰따옴표 없는 대문자 식별자 사용 | + +## 9. 제외 범위 + +- 이번 단계에서는 ADB 직원과 선사의 담당 배정 테이블 및 VPD 정책을 만들지 않는다. +- Select AI profile과 MCP tool 등록은 로컬 View 검증 후 후속 단계에서 수행한다. +- 실제 실행은 녹화를 진행하는 사용자가 SQL Worksheet에서 수행한다. diff --git a/sql/adb/73_hmm_rds_pg_00_precheck.sql b/sql/adb/73_hmm_rds_pg_00_precheck.sql new file mode 100644 index 0000000..dc207ac --- /dev/null +++ b/sql/adb/73_hmm_rds_pg_00_precheck.sql @@ -0,0 +1,32 @@ +-- ============================================================ +-- HMM ADB -> AWS RDS PostgreSQL federation demo +-- Step 0: precheck +-- Run in Database Actions SQL Worksheet as ADMIN. +-- Redmine: #730 +-- ============================================================ + +SELECT + USER AS session_user, + SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS current_schema, + SYS_CONTEXT('USERENV', 'DB_NAME') AS database_name +FROM dual; + +SELECT credential_name, username +FROM all_credentials +WHERE credential_name = 'HMM_RDS_PG_CRED'; + +SELECT db_link, username, host, created +FROM user_db_links +WHERE db_link = 'HMM_RDS_PG_LINK'; + +SELECT object_name, object_type, status +FROM user_objects +WHERE object_name IN ( + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' +) +ORDER BY object_name; + +-- First recording: the last three queries should return no rows. +-- If objects already exist, run 80_hmm_rds_pg_99_cleanup.sql first. diff --git a/sql/adb/74_hmm_rds_pg_01_credential.sql b/sql/adb/74_hmm_rds_pg_01_credential.sql new file mode 100644 index 0000000..40e269b --- /dev/null +++ b/sql/adb/74_hmm_rds_pg_01_credential.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- Step 1: create the PostgreSQL credential. +-- Run only this statement after replacing in the +-- Worksheet. Never save the actual password in this file. +-- Redmine: #730 +-- ============================================================ + +BEGIN + DBMS_CLOUD.CREATE_CREDENTIAL( + credential_name => 'HMM_RDS_PG_CRED', + username => 'postgres', + password => '' + ); +END; +/ + +-- Passwords are never returned by ALL_CREDENTIALS. +SELECT credential_name, username +FROM all_credentials +WHERE credential_name = 'HMM_RDS_PG_CRED'; diff --git a/sql/adb/75_hmm_rds_pg_02_acl.sql b/sql/adb/75_hmm_rds_pg_02_acl.sql new file mode 100644 index 0000000..4317dd1 --- /dev/null +++ b/sql/adb/75_hmm_rds_pg_02_acl.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- Step 2: register and verify the host-specific ACL. +-- Run as ADMIN. +-- +-- Important: +-- Oracle-managed heterogeneous Database Link traffic uses the +-- managed gateway. This ACL records SQL-user host permissions; +-- it does not replace the AWS RDS Security Group rule. +-- Redmine: #730 +-- ============================================================ + +-- DNS resolution privilege must not have a port range. +BEGIN + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE( + host => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com', + ace => XS$ACE_TYPE( + privilege_list => XS$NAME_LIST('resolve'), + principal_name => SYS_CONTEXT('USERENV', 'SESSION_USER'), + principal_type => XS_ACL.PTYPE_DB + ) + ); +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -24243 THEN + RAISE; + END IF; +END; +/ + +-- Permit only the PostgreSQL service port. +BEGIN + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE( + host => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com', + lower_port => 5432, + upper_port => 5432, + ace => XS$ACE_TYPE( + privilege_list => XS$NAME_LIST('connect'), + principal_name => SYS_CONTEXT('USERENV', 'SESSION_USER'), + principal_type => XS_ACL.PTYPE_DB + ) + ); +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -24243 THEN + RAISE; + END IF; +END; +/ + +SELECT + host, + lower_port, + upper_port, + principal, + privilege, + grant_type +FROM dba_host_aces +WHERE host = 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com' + AND principal = USER + AND privilege IN ('resolve', 'connect') +ORDER BY privilege, lower_port; diff --git a/sql/adb/76_hmm_rds_pg_03_dblink.sql b/sql/adb/76_hmm_rds_pg_03_dblink.sql new file mode 100644 index 0000000..04b665c --- /dev/null +++ b/sql/adb/76_hmm_rds_pg_03_dblink.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- Step 3: create an Oracle-managed PostgreSQL Database Link. +-- Based on FY26 internal training, section 3.2.3. +-- Run as ADMIN. +-- Redmine: #730 +-- ============================================================ + +BEGIN + DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK( + db_link_name => 'HMM_RDS_PG_LINK', + hostname => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com', + port => 5432, + service_name => 'postgres', + credential_name => 'HMM_RDS_PG_CRED', + ssl_server_cert_dn => NULL, + private_target => FALSE, + gateway_params => JSON_OBJECT('db_type' VALUE 'postgres') + ); +END; +/ + +SELECT db_link, username, host, created +FROM user_db_links +WHERE db_link = 'HMM_RDS_PG_LINK'; diff --git a/sql/adb/77_hmm_rds_pg_04_remote_query.sql b/sql/adb/77_hmm_rds_pg_04_remote_query.sql new file mode 100644 index 0000000..2f7a7d4 --- /dev/null +++ b/sql/adb/77_hmm_rds_pg_04_remote_query.sql @@ -0,0 +1,34 @@ +-- ============================================================ +-- Step 4: query PostgreSQL directly through the Database Link. +-- PostgreSQL schema, table, view, and column identifiers are +-- lower-case and must be double quoted. +-- Redmine: #730 +-- ============================================================ + +SELECT COUNT(*) AS carrier_count +FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK; + +SELECT + COUNT(*) AS performance_count, + MIN("performance_month") AS first_month, + MAX("performance_month") AS latest_month +FROM "hmm_demo"."carrier_monthly_performance"@HMM_RDS_PG_LINK; + +SELECT + "carrier_code", + "carrier_name", + "service_region", + "performance_tier" +FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK +ORDER BY "carrier_code"; + +SELECT + "carrier_code", + "carrier_name", + "performance_month", + "shipped_teu", + "revenue_usd", + "schedule_reliability_pct", + "risk_level" +FROM "hmm_demo"."carrier_performance_latest_v"@HMM_RDS_PG_LINK +ORDER BY "carrier_code"; diff --git a/sql/adb/78_hmm_rds_pg_05_views.sql b/sql/adb/78_hmm_rds_pg_05_views.sql new file mode 100644 index 0000000..b967745 --- /dev/null +++ b/sql/adb/78_hmm_rds_pg_05_views.sql @@ -0,0 +1,109 @@ +-- ============================================================ +-- Step 5: create local Oracle Views and Select AI metadata. +-- Explicit aliases normalize PostgreSQL lower-case identifiers +-- into ordinary Oracle identifiers. +-- Redmine: #730 +-- ============================================================ + +CREATE OR REPLACE VIEW HMM_RDS_CARRIERS_V AS +SELECT + "carrier_code" AS carrier_code, + "carrier_name" AS carrier_name, + "country_code" AS country_code, + "alliance_name" AS alliance_name, + "service_region" AS service_region, + "performance_tier" AS performance_tier +FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK; + +COMMENT ON TABLE HMM_RDS_CARRIERS_V IS + 'HMM federation demo fictional carrier master. Primary Key: CARRIER_CODE. Referenced by HMM_RDS_CARRIER_PERF_V(CARRIER_CODE).'; +COMMENT ON COLUMN HMM_RDS_CARRIERS_V.CARRIER_CODE IS + 'Primary Key. Fictional carrier identifier such as C001.'; +COMMENT ON COLUMN HMM_RDS_CARRIERS_V.CARRIER_NAME IS + 'Fictional carrier display name; not an actual carrier.'; +COMMENT ON COLUMN HMM_RDS_CARRIERS_V.COUNTRY_CODE IS + 'Two-letter demo country code.'; +COMMENT ON COLUMN HMM_RDS_CARRIERS_V.ALLIANCE_NAME IS + 'Fictional carrier cooperation network.'; +COMMENT ON COLUMN HMM_RDS_CARRIERS_V.SERVICE_REGION IS + 'Primary service region for the demo carrier.'; +COMMENT ON COLUMN HMM_RDS_CARRIERS_V.PERFORMANCE_TIER IS + 'Demo portfolio tier: CORE, GROWTH, or WATCH.'; + +CREATE OR REPLACE VIEW HMM_RDS_CARRIER_PERF_V AS +SELECT + "carrier_code" AS carrier_code, + "performance_month" AS performance_month, + "contracted_teu" AS contracted_teu, + "shipped_teu" AS shipped_teu, + "revenue_usd" AS revenue_usd, + "gross_margin_usd" AS gross_margin_usd, + "schedule_reliability_pct" AS schedule_reliability_pct, + "vessel_utilization_pct" AS vessel_utilization_pct, + "claim_rate_pct" AS claim_rate_pct, + "yoy_growth_pct" AS yoy_growth_pct, + "risk_level" AS risk_level +FROM "hmm_demo"."carrier_monthly_performance"@HMM_RDS_PG_LINK; + +COMMENT ON TABLE HMM_RDS_CARRIER_PERF_V IS + 'HMM federation demo monthly carrier KPI. Composite Primary Key: CARRIER_CODE and PERFORMANCE_MONTH. Foreign Key: CARRIER_CODE references HMM_RDS_CARRIERS_V(CARRIER_CODE). All values are fictional.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CARRIER_CODE IS + 'Foreign Key referencing HMM_RDS_CARRIERS_V(CARRIER_CODE).'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.PERFORMANCE_MONTH IS + 'First calendar day of the KPI month; part of the composite Primary Key.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CONTRACTED_TEU IS + 'Contracted container volume in TEU.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.SHIPPED_TEU IS + 'Actually shipped container volume in TEU.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.REVENUE_USD IS + 'Fictional monthly revenue in USD.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.GROSS_MARGIN_USD IS + 'Fictional monthly gross margin in USD; negative values indicate a loss.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.SCHEDULE_RELIABILITY_PCT IS + 'Schedule reliability percentage from 0 through 100.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.VESSEL_UTILIZATION_PCT IS + 'Vessel capacity utilization percentage from 0 through 100.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CLAIM_RATE_PCT IS + 'Cargo claim rate percentage from 0 through 100.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.YOY_GROWTH_PCT IS + 'Year-over-year performance growth percentage.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.RISK_LEVEL IS + 'Derived demo risk category: GREEN, AMBER, or RED.'; + +CREATE OR REPLACE VIEW HMM_RDS_CARRIER_LATEST_V AS +SELECT + "carrier_code" AS carrier_code, + "carrier_name" AS carrier_name, + "country_code" AS country_code, + "alliance_name" AS alliance_name, + "service_region" AS service_region, + "performance_tier" AS performance_tier, + "performance_month" AS performance_month, + "contracted_teu" AS contracted_teu, + "shipped_teu" AS shipped_teu, + "revenue_usd" AS revenue_usd, + "gross_margin_usd" AS gross_margin_usd, + "schedule_reliability_pct" AS schedule_reliability_pct, + "vessel_utilization_pct" AS vessel_utilization_pct, + "claim_rate_pct" AS claim_rate_pct, + "yoy_growth_pct" AS yoy_growth_pct, + "risk_level" AS risk_level +FROM "hmm_demo"."carrier_performance_latest_v"@HMM_RDS_PG_LINK; + +COMMENT ON TABLE HMM_RDS_CARRIER_LATEST_V IS + 'Latest fictional monthly KPI for each HMM federation demo carrier. Primary Key: CARRIER_CODE.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE IS + 'Primary Key for the latest carrier KPI row.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.PERFORMANCE_MONTH IS + 'Latest KPI month available in the PostgreSQL source.'; +COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.RISK_LEVEL IS + 'Latest derived risk category: GREEN, AMBER, or RED.'; + +SELECT object_name, object_type, status +FROM user_objects +WHERE object_name IN ( + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' +) +ORDER BY object_name; diff --git a/sql/adb/79_hmm_rds_pg_06_verify.sql b/sql/adb/79_hmm_rds_pg_06_verify.sql new file mode 100644 index 0000000..84f5960 --- /dev/null +++ b/sql/adb/79_hmm_rds_pg_06_verify.sql @@ -0,0 +1,86 @@ +-- ============================================================ +-- Step 6: verify the local federation Views. +-- Expected: carrier/performance/latest = 8/144/8. +-- Redmine: #730 +-- ============================================================ + +DECLARE + v_carrier_count PLS_INTEGER; + v_performance_count PLS_INTEGER; + v_latest_count PLS_INTEGER; + v_risk_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) INTO v_carrier_count + FROM HMM_RDS_CARRIERS_V; + + SELECT COUNT(*) INTO v_performance_count + FROM HMM_RDS_CARRIER_PERF_V; + + SELECT COUNT(*), COUNT(DISTINCT risk_level) + INTO v_latest_count, v_risk_count + FROM HMM_RDS_CARRIER_LATEST_V; + + IF v_carrier_count != 8 THEN + RAISE_APPLICATION_ERROR( + -20001, + 'Expected 8 carriers, found ' || v_carrier_count + ); + END IF; + + IF v_performance_count != 144 THEN + RAISE_APPLICATION_ERROR( + -20002, + 'Expected 144 performance rows, found ' || v_performance_count + ); + END IF; + + IF v_latest_count != 8 OR v_risk_count != 3 THEN + RAISE_APPLICATION_ERROR( + -20003, + 'Expected 8 latest rows and 3 risk levels, found ' + || v_latest_count || ' rows and ' || v_risk_count || ' levels' + ); + END IF; +END; +/ + +SELECT + (SELECT COUNT(*) FROM HMM_RDS_CARRIERS_V) AS carrier_count, + (SELECT COUNT(*) FROM HMM_RDS_CARRIER_PERF_V) AS performance_count, + (SELECT COUNT(*) FROM HMM_RDS_CARRIER_LATEST_V) AS latest_count +FROM dual; + +SELECT + MIN(performance_month) AS first_month, + MAX(performance_month) AS latest_month, + COUNT(DISTINCT carrier_code) AS carrier_count +FROM HMM_RDS_CARRIER_PERF_V; + +SELECT risk_level, COUNT(*) AS carrier_count +FROM HMM_RDS_CARRIER_LATEST_V +GROUP BY risk_level +ORDER BY risk_level; + +SELECT + carrier_code, + carrier_name, + performance_month, + shipped_teu, + revenue_usd, + gross_margin_usd, + schedule_reliability_pct, + claim_rate_pct, + risk_level +FROM HMM_RDS_CARRIER_LATEST_V +ORDER BY carrier_code; + +SELECT + table_name, + comments +FROM user_tab_comments +WHERE table_name IN ( + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' +) +ORDER BY table_name; diff --git a/sql/adb/80_hmm_rds_pg_99_cleanup.sql b/sql/adb/80_hmm_rds_pg_99_cleanup.sql new file mode 100644 index 0000000..27e06d3 --- /dev/null +++ b/sql/adb/80_hmm_rds_pg_99_cleanup.sql @@ -0,0 +1,63 @@ +-- ============================================================ +-- Optional pre-recording reset. +-- Drops only the three local Views, Database Link, and +-- Credential owned by this demo. Host ACL entries are retained. +-- Redmine: #730 +-- ============================================================ + +DECLARE + v_count PLS_INTEGER; +BEGIN + FOR object_row IN ( + SELECT object_name + FROM user_objects + WHERE object_type = 'VIEW' + AND object_name IN ( + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' + ) + ORDER BY CASE object_name + WHEN 'HMM_RDS_CARRIER_LATEST_V' THEN 1 + WHEN 'HMM_RDS_CARRIER_PERF_V' THEN 2 + ELSE 3 + END + ) LOOP + EXECUTE IMMEDIATE 'DROP VIEW ' || object_row.object_name; + END LOOP; + + SELECT COUNT(*) INTO v_count + FROM user_db_links + WHERE db_link = 'HMM_RDS_PG_LINK'; + + IF v_count = 1 THEN + EXECUTE IMMEDIATE 'DROP DATABASE LINK HMM_RDS_PG_LINK'; + END IF; + + SELECT COUNT(*) INTO v_count + FROM all_credentials + WHERE credential_name = 'HMM_RDS_PG_CRED'; + + IF v_count = 1 THEN + DBMS_CLOUD.DROP_CREDENTIAL( + credential_name => 'HMM_RDS_PG_CRED' + ); + END IF; +END; +/ + +SELECT credential_name, username +FROM all_credentials +WHERE credential_name = 'HMM_RDS_PG_CRED'; + +SELECT db_link, username, host +FROM user_db_links +WHERE db_link = 'HMM_RDS_PG_LINK'; + +SELECT object_name, object_type +FROM user_objects +WHERE object_name IN ( + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' +); From 9bed75f0c042ed791645bac43e11ea95de107607 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 24 Jul 2026 14:43:45 +0900 Subject: [PATCH 15/18] refs #730: map carrier access groups to RDS performance --- .../README.md | 37 +++- sql/adb/81_hmm_carrier_access_groups.sql | 209 ++++++++++++++++++ ...2_hmm_federation_relationship_comments.sql | 67 ++++++ 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 sql/adb/81_hmm_carrier_access_groups.sql create mode 100644 sql/adb/82_hmm_federation_relationship_comments.sql diff --git a/docs/design/730-adb-rds-postgres-dblink-demo/README.md b/docs/design/730-adb-rds-postgres-dblink-demo/README.md index 08a2173..48edb2a 100644 --- a/docs/design/730-adb-rds-postgres-dblink-demo/README.md +++ b/docs/design/730-adb-rds-postgres-dblink-demo/README.md @@ -95,6 +95,36 @@ View comment에 기록한다. 각 View는 원격 소문자 컬럼을 Oracle의 일반 대문자 식별자로 명시적으로 alias한다. 따라서 후속 SQL과 Select AI metadata에서는 큰따옴표 없이 안정적으로 사용할 수 있다. +### 5.4 기존 접근 그룹을 이용한 직원별 선사 배정 + +직원별 담당 선사는 신규 업무 테이블을 만들지 않고 기존 백오피스 접근 그룹 모델을 +재사용한다. + +| 기존 객체 | 선사 배정에서의 역할 | +|---|---| +| `HMM_HR_EMPLOYEES` | 직원, 팀장, 팀 관계 | +| `HMM_ACCESS_GROUPS` | `CARRIER_C001`부터 `CARRIER_C008`까지 선사 접근 그룹 | +| `HMM_ACCESS_GROUP_MEMBERS` | 직원과 담당 선사의 연결 | +| `HMM_CARRIER_ASSIGNMENTS_V` | 기존 세 테이블을 Select AI가 사용하기 쉬운 형태로 정규화 | + +`HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE`는 그룹 코드에서 `CARRIER_` 접두어를 제거해 +만들며, PostgreSQL 기반 `HMM_RDS_*_V.CARRIER_CODE`와 조인한다. 이 구조를 사용하면 기존 +백오피스의 접근 그룹 화면에서 담당자 변경이 가능하고 별도 관리 화면이나 중복 테이블이 +필요하지 않다. + +`HMM_ACCESS_PERMISSION_RULES`는 행 접근 정책을 정의하는 보안 메타데이터이므로 담당 선사 +원장으로 사용하지 않는다. + +Select AI가 이 관계를 추론에만 의존하지 않도록 네 View의 table comment와 +`CARRIER_CODE` column comment 양쪽에 상대 객체명과 조인 키를 기록한다. + +- `HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE` + ↔ `HMM_RDS_CARRIERS_V.CARRIER_CODE` +- `HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE` + ↔ `HMM_RDS_CARRIER_PERF_V.CARRIER_CODE` +- `HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE` + ↔ `HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE` + ## 6. 녹화용 실행 파일 | 순서 | 파일 | 화면에서 확인할 결과 | @@ -107,6 +137,8 @@ View comment에 기록한다. | 5 | `78_hmm_rds_pg_05_views.sql` | Oracle 로컬 View 3개 | | 6 | `79_hmm_rds_pg_06_verify.sql` | `8 / 144 / 8`, 위험도 3종 | | 재촬영 전 | `80_hmm_rds_pg_99_cleanup.sql` | View, DB Link, Credential만 제거 | +| 7 | `81_hmm_carrier_access_groups.sql` | 선사 그룹 8건, 구성원 8건, federation 조인 8건 | +| 8 | `82_hmm_federation_relationship_comments.sql` | 양쪽 View의 명시적 조인 관계 metadata | ## 7. 성공 기준 @@ -116,6 +148,8 @@ View comment에 기록한다. - 원격 직접 조회가 선사 8건과 실적 144건을 반환한다. - 로컬 View 3개가 `VALID` 상태다. - 최신 View가 8건을 반환하고 `GREEN`, `AMBER`, `RED`가 모두 존재한다. +- `CARRIER_%` 접근 그룹 8개와 직원-그룹 구성원 8건이 존재한다. +- `HMM_CARRIER_ASSIGNMENTS_V`와 `HMM_RDS_CARRIER_LATEST_V`의 조인이 8건을 반환한다. - Credential password가 SQL 파일, Git diff, 화면 출력에 남지 않는다. ## 8. 실패 시 판별 @@ -132,6 +166,7 @@ View comment에 기록한다. ## 9. 제외 범위 -- 이번 단계에서는 ADB 직원과 선사의 담당 배정 테이블 및 VPD 정책을 만들지 않는다. +- 별도 직원-선사 담당 테이블은 만들지 않고 기존 접근 그룹 객체를 재사용한다. +- 선사별 VPD 정책 적용은 후속 단계에서 수행한다. - Select AI profile과 MCP tool 등록은 로컬 View 검증 후 후속 단계에서 수행한다. - 실제 실행은 녹화를 진행하는 사용자가 SQL Worksheet에서 수행한다. diff --git a/sql/adb/81_hmm_carrier_access_groups.sql b/sql/adb/81_hmm_carrier_access_groups.sql new file mode 100644 index 0000000..03e12ff --- /dev/null +++ b/sql/adb/81_hmm_carrier_access_groups.sql @@ -0,0 +1,209 @@ +-- ============================================================ +-- HMM carrier assignments using existing access-group tables. +-- +-- Existing tables: +-- HMM_HR_EMPLOYEES +-- HMM_ACCESS_GROUPS +-- HMM_ACCESS_GROUP_MEMBERS +-- +-- No new assignment table is created. +-- Korean values are reconstructed from UTF-8 base64 so SQLcl +-- cannot corrupt the literals. +-- Redmine: #730 +-- ============================================================ + +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON +SET SERVEROUTPUT ON + +MERGE INTO hmm_access_groups dst +USING ( + SELECT + base.max_group_id + carrier.seq_no AS group_id, + 'CARRIER_' || carrier.carrier_code AS group_code, + carrier.carrier_code || ' ' || + utl_i18n.raw_to_char( + utl_encode.base64_decode( + utl_raw.cast_to_raw('7ISg7IKsIOuLtOuLuSDqt7jro7k=') + ), + 'AL32UTF8' + ) AS group_name, + utl_i18n.raw_to_char( + utl_encode.base64_decode( + utl_raw.cast_to_raw( + '642w66qo7JqpIOyEoOyCrCDsoJHqt7wg67CPIOuLtOuLueyekCDqt7jro7k=' + ) + ), + 'AL32UTF8' + ) || ' (' || carrier.carrier_code || ')' AS description + FROM ( + SELECT 1 seq_no, 'C001' carrier_code FROM dual + UNION ALL SELECT 2, 'C002' FROM dual + UNION ALL SELECT 3, 'C003' FROM dual + UNION ALL SELECT 4, 'C004' FROM dual + UNION ALL SELECT 5, 'C005' FROM dual + UNION ALL SELECT 6, 'C006' FROM dual + UNION ALL SELECT 7, 'C007' FROM dual + UNION ALL SELECT 8, 'C008' FROM dual + ) carrier + CROSS JOIN ( + SELECT NVL(MAX(group_id), 0) max_group_id + FROM hmm_access_groups + ) base +) src +ON (dst.group_code = src.group_code) +WHEN MATCHED THEN UPDATE SET + dst.group_name = src.group_name, + dst.description = src.description, + dst.active_yn = 'Y', + dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + group_id, + group_code, + group_name, + description, + active_yn, + created_at, + updated_at +) VALUES ( + src.group_id, + src.group_code, + src.group_name, + src.description, + 'Y', + SYSTIMESTAMP, + SYSTIMESTAMP +); + +MERGE INTO hmm_access_group_members dst +USING ( + SELECT + access_group.group_id, + employee.employee_id + FROM ( + SELECT 'E1002' employee_code, 'CARRIER_C001' group_code FROM dual + UNION ALL SELECT 'E1002', 'CARRIER_C002' FROM dual + UNION ALL SELECT 'E1003', 'CARRIER_C003' FROM dual + UNION ALL SELECT 'E1004', 'CARRIER_C004' FROM dual + UNION ALL SELECT 'E1005', 'CARRIER_C005' FROM dual + UNION ALL SELECT 'E1006', 'CARRIER_C006' FROM dual + UNION ALL SELECT 'E1006', 'CARRIER_C007' FROM dual + UNION ALL SELECT 'E1007', 'CARRIER_C008' FROM dual + ) assignment + JOIN hmm_hr_employees employee + ON employee.employee_code = assignment.employee_code + AND employee.employment_status = 'ACTIVE' + JOIN hmm_access_groups access_group + ON access_group.group_code = assignment.group_code + AND access_group.active_yn = 'Y' +) src +ON ( + dst.group_id = src.group_id + AND dst.employee_id = src.employee_id +) +WHEN NOT MATCHED THEN INSERT ( + group_id, + employee_id, + created_at +) VALUES ( + src.group_id, + src.employee_id, + SYSTIMESTAMP +); + +CREATE OR REPLACE VIEW hmm_carrier_assignments_v AS +SELECT + employee.employee_id, + employee.employee_code, + employee.full_name AS employee_name, + employee.job_title, + employee.team_id, + employee.manager_employee_id, + manager.employee_code AS manager_employee_code, + manager.full_name AS manager_name, + SUBSTR(access_group.group_code, LENGTH('CARRIER_') + 1) AS carrier_code, + access_group.group_code, + access_group.group_name +FROM hmm_access_group_members membership +JOIN hmm_access_groups access_group + ON access_group.group_id = membership.group_id + AND access_group.active_yn = 'Y' +JOIN hmm_hr_employees employee + ON employee.employee_id = membership.employee_id + AND employee.employment_status = 'ACTIVE' +LEFT JOIN hmm_hr_employees manager + ON manager.employee_id = employee.manager_employee_id +WHERE access_group.group_code LIKE 'CARRIER_C%'; + +COMMENT ON TABLE hmm_carrier_assignments_v IS + 'ADB employee-to-carrier assignments from HMM_HR_EMPLOYEES, HMM_ACCESS_GROUPS, and HMM_ACCESS_GROUP_MEMBERS. Join relationship: HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE joins HMM_RDS_CARRIERS_V.CARRIER_CODE, HMM_RDS_CARRIER_PERF_V.CARRIER_CODE, and HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.'; +COMMENT ON COLUMN hmm_carrier_assignments_v.EMPLOYEE_CODE IS + 'HMM employee code sourced from HMM_HR_EMPLOYEES.EMPLOYEE_CODE. Example: E1002.'; +COMMENT ON COLUMN hmm_carrier_assignments_v.MANAGER_EMPLOYEE_CODE IS + 'Direct manager employee code sourced from HMM_HR_EMPLOYEES. Use E1001 to identify the HMM demo manager team.'; +COMMENT ON COLUMN hmm_carrier_assignments_v.CARRIER_CODE IS + 'Join Key and Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).'; +COMMENT ON COLUMN hmm_carrier_assignments_v.GROUP_CODE IS + 'Existing access-group code in the form CARRIER_C001.'; + +COMMIT; + +DECLARE + v_group_count PLS_INTEGER; + v_member_count PLS_INTEGER; + v_assignment_count PLS_INTEGER; + v_federated_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) + INTO v_group_count + FROM hmm_access_groups + WHERE group_code BETWEEN 'CARRIER_C001' AND 'CARRIER_C008' + AND active_yn = 'Y'; + + SELECT COUNT(*) + INTO v_member_count + FROM hmm_access_group_members membership + JOIN hmm_access_groups access_group + ON access_group.group_id = membership.group_id + WHERE access_group.group_code BETWEEN 'CARRIER_C001' AND 'CARRIER_C008'; + + SELECT COUNT(*) + INTO v_assignment_count + FROM hmm_carrier_assignments_v; + + SELECT COUNT(*) + INTO v_federated_count + FROM hmm_carrier_assignments_v assignment + JOIN hmm_rds_carrier_latest_v performance + ON performance.carrier_code = assignment.carrier_code; + + IF v_group_count != 8 + OR v_member_count != 8 + OR v_assignment_count != 8 + OR v_federated_count != 8 THEN + RAISE_APPLICATION_ERROR( + -20081, + 'Expected group/member/assignment/federated counts 8/8/8/8; found ' + || v_group_count || '/' + || v_member_count || '/' + || v_assignment_count || '/' + || v_federated_count + ); + END IF; +END; +/ + +SELECT + assignment.employee_code, + assignment.employee_name, + assignment.manager_employee_code, + assignment.carrier_code, + performance.carrier_name, + performance.performance_month, + performance.revenue_usd, + performance.risk_level +FROM hmm_carrier_assignments_v assignment +JOIN hmm_rds_carrier_latest_v performance + ON performance.carrier_code = assignment.carrier_code +ORDER BY assignment.employee_code, assignment.carrier_code; diff --git a/sql/adb/82_hmm_federation_relationship_comments.sql b/sql/adb/82_hmm_federation_relationship_comments.sql new file mode 100644 index 0000000..a5418ca --- /dev/null +++ b/sql/adb/82_hmm_federation_relationship_comments.sql @@ -0,0 +1,67 @@ +-- ============================================================ +-- Explicit Select AI relationship metadata for ADB + RDS Views. +-- Redmine: #730 +-- ============================================================ + +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +COMMENT ON TABLE hmm_carrier_assignments_v IS + 'ADB employee-to-carrier assignments from HMM_HR_EMPLOYEES, HMM_ACCESS_GROUPS, and HMM_ACCESS_GROUP_MEMBERS. Join relationship: HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE joins HMM_RDS_CARRIERS_V.CARRIER_CODE, HMM_RDS_CARRIER_PERF_V.CARRIER_CODE, and HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.'; + +COMMENT ON COLUMN hmm_carrier_assignments_v.EMPLOYEE_CODE IS + 'HMM employee code sourced from HMM_HR_EMPLOYEES.EMPLOYEE_CODE. Example: E1002.'; + +COMMENT ON COLUMN hmm_carrier_assignments_v.MANAGER_EMPLOYEE_CODE IS + 'Direct manager employee code sourced from HMM_HR_EMPLOYEES. Use E1001 to identify the HMM demo manager team.'; + +COMMENT ON COLUMN hmm_carrier_assignments_v.CARRIER_CODE IS + 'Join Key and Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).'; + +COMMENT ON TABLE hmm_rds_carriers_v IS + 'PostgreSQL fictional carrier master exposed through HMM_RDS_PG_LINK. Primary Key: CARRIER_CODE. Join relationship: HMM_RDS_CARRIERS_V.CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE and HMM_RDS_CARRIER_PERF_V.CARRIER_CODE.'; + +COMMENT ON COLUMN hmm_rds_carriers_v.CARRIER_CODE IS + 'Primary Key. Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).'; + +COMMENT ON TABLE hmm_rds_carrier_perf_v IS + 'PostgreSQL monthly carrier KPI exposed through HMM_RDS_PG_LINK. Composite Primary Key: CARRIER_CODE and PERFORMANCE_MONTH. Foreign Key: CARRIER_CODE references HMM_RDS_CARRIERS_V(CARRIER_CODE). Join relationship: CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE.'; + +COMMENT ON COLUMN hmm_rds_carrier_perf_v.CARRIER_CODE IS + 'Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE). Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE).'; + +COMMENT ON COLUMN hmm_rds_carrier_perf_v.PERFORMANCE_MONTH IS + 'Part of the composite Primary Key with CARRIER_CODE. First calendar day of the KPI month.'; + +COMMENT ON TABLE hmm_rds_carrier_latest_v IS + 'Latest PostgreSQL KPI for each fictional carrier exposed through HMM_RDS_PG_LINK. Primary Key: CARRIER_CODE. Join relationship: HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE and HMM_RDS_CARRIERS_V.CARRIER_CODE.'; + +COMMENT ON COLUMN hmm_rds_carrier_latest_v.CARRIER_CODE IS + 'Primary Key. Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE) and HMM_RDS_CARRIERS_V(CARRIER_CODE).'; + +SELECT table_name, comments +FROM user_tab_comments +WHERE table_name IN ( + 'HMM_CARRIER_ASSIGNMENTS_V', + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' +) +ORDER BY table_name; + +SELECT table_name, column_name, comments +FROM user_col_comments +WHERE table_name IN ( + 'HMM_CARRIER_ASSIGNMENTS_V', + 'HMM_RDS_CARRIERS_V', + 'HMM_RDS_CARRIER_PERF_V', + 'HMM_RDS_CARRIER_LATEST_V' +) + AND column_name IN ( + 'EMPLOYEE_CODE', + 'MANAGER_EMPLOYEE_CODE', + 'CARRIER_CODE', + 'PERFORMANCE_MONTH' + ) +ORDER BY table_name, column_name; From af6add5a44be998d19742d90d4f99708b83eaf53 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 24 Jul 2026 15:48:28 +0900 Subject: [PATCH 16/18] refs #732: expose carrier federation portal scenarios --- .../apps/poc4/mcp_discovery_ui.py | 6 ++++- .../config/hmm_demo_scenarios.json | 21 ++++++++++++++++++ .../config/mcp_servers.json | 5 +++-- .../tests/test_scenarios.py | 22 +++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index 5fe0060..c1510ca 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -5349,6 +5349,8 @@ def _prepare_hmm_hr_tool_query( team=selected_user_team.strip(), scope=selected_user_scope.strip(), ) + if tool.name == "search_carrier_performance": + return contextual_question try: profile = resolve_model_profile(model_profile_key) client = build_oci_genai_completion_client( @@ -5363,7 +5365,9 @@ def _prepare_hmm_hr_tool_query( "or expose/request tokens. Preserve employee codes exactly. " "The HMM HR data tool can query organization, employees, leave balances, " "leave requests, attendance, and standardized HR terms. The policy tool searches " - "HR policy PDF abstracts and chunks. A selected demo user only resolves pronouns " + "HR policy PDF abstracts and chunks. The carrier federation tool joins ADB " + "employee assignments with PostgreSQL carrier KPI data by CARRIER_CODE. " + "A selected demo user only resolves pronouns " "such as 'my' or 'our team'; do not claim that it enforces database access control." ), user_prompt=json.dumps( diff --git a/poc4_active_source_20260714/config/hmm_demo_scenarios.json b/poc4_active_source_20260714/config/hmm_demo_scenarios.json index f50a9b4..4f032db 100644 --- a/poc4_active_source_20260714/config/hmm_demo_scenarios.json +++ b/poc4_active_source_20260714/config/hmm_demo_scenarios.json @@ -36,6 +36,27 @@ "category": "근태 현황", "title": "휴가 현황 조회", "question": "이번 달 팀원별 휴가 사용 현황을 보여줘" + }, + { + "id": "FED-01", + "enabled": true, + "category": "선사 실적 Federation", + "title": "팀원별 담당 선사 최신 실적", + "question": "E1001 팀장의 팀원별 담당 선사와 해당 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘" + }, + { + "id": "FED-02", + "enabled": true, + "category": "선사 실적 Federation", + "title": "위험 선사와 담당자", + "question": "E1001 팀에서 최신 위험 등급이 RED인 선사를 담당하는 직원, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘" + }, + { + "id": "FED-03", + "enabled": true, + "category": "선사 실적 Federation", + "title": "담당 선사 월별 추이", + "question": "E1006 직원이 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘" } ] } diff --git a/poc4_active_source_20260714/config/mcp_servers.json b/poc4_active_source_20260714/config/mcp_servers.json index 83c305c..8a6242c 100644 --- a/poc4_active_source_20260714/config/mcp_servers.json +++ b/poc4_active_source_20260714/config/mcp_servers.json @@ -14,9 +14,10 @@ "tool_allowlist": [ "search_hr_data", "resolve_hr_term", - "search_hr_policy" + "search_hr_policy", + "search_carrier_performance" ], - "description": "HMM HR knowledge and data MCP server" + "description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server" } ] } diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py index b9f29eb..d819430 100644 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ b/poc4_active_source_20260714/tests/test_scenarios.py @@ -87,6 +87,28 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertGreaterEqual(len(scenarios), 3) self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios})) self.assertTrue(all(item.question.strip() for item in scenarios)) + by_id = {item.scenario_id: item for item in scenarios} + self.assertEqual( + {"FED-01", "FED-02", "FED-03"}, + {"FED-01", "FED-02", "FED-03"} & set(by_id), + ) + self.assertTrue( + all("선사" in by_id[scenario_id].question for scenario_id in ( + "FED-01", "FED-02", "FED-03" + )) + ) + + def test_hmm_mcp_allows_carrier_federation_tool(self) -> None: + path = Path(__file__).parents[1] / "config" / "mcp_servers.json" + payload = json.loads(path.read_text(encoding="utf-8")) + server = next( + item for item in payload["servers"] if item["id"] == "hmm_hr_mcp" + ) + + self.assertIn( + "search_carrier_performance", + server["tool_allowlist"], + ) def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None: path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json" From 2e44ed0b97c8a9dd6a955e831d43570370c88e38 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 31 Jul 2026 13:57:04 +0900 Subject: [PATCH 17/18] refs #740: enforce HMM MCP VPD runtime boundary --- .env.example | 11 +- docs/design/740-hmm-mcp-vpd-runtime/README.md | 123 ++++++++++++++ docs/runbooks/hmm-mcp-token-configuration.md | 35 +++- sql/adb/73_hmm_mcp_vpd_runtime.sql | 159 ++++++++++++++++++ .../config/BackofficeProperties.java | 12 +- .../service/SelectAiService.java | 97 +++++++++-- src/main/resources/application.yml | 11 +- .../service/McpSseServiceTest.java | 46 +++++ 8 files changed, 468 insertions(+), 26 deletions(-) create mode 100644 docs/design/740-hmm-mcp-vpd-runtime/README.md create mode 100644 sql/adb/73_hmm_mcp_vpd_runtime.sql diff --git a/.env.example b/.env.example index 03ac372..f36b100 100644 --- a/.env.example +++ b/.env.example @@ -52,11 +52,16 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_PASSWORD="" -# Select AI 프로파일 소유 스키마의 별도 연결. MCP 도구가 AGENT_TOOL만 사용하면 비워 둘 수 있습니다. +# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다. export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" -export BACKOFFICE_SELECT_AI_DB_USERNAME="" -export BACKOFFICE_SELECT_AI_DB_PASSWORD="" +export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}" +export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}" export BACKOFFICE_SELECT_AI_PROFILE="" +# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다. +# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요. +export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}" +export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS" +export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD="" # --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 --- # 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다. diff --git a/docs/design/740-hmm-mcp-vpd-runtime/README.md b/docs/design/740-hmm-mcp-vpd-runtime/README.md new file mode 100644 index 0000000..c26c705 --- /dev/null +++ b/docs/design/740-hmm-mcp-vpd-runtime/README.md @@ -0,0 +1,123 @@ +# #740 HMM MCP VPD 실행 경계 복구 + +## 배경 + +HMM MCP의 `search_hr_data`는 현재 `ADMIN` JDBC 세션에서 +`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 실행한다. 토큰으로 +`HMM_ACCESS_CTX`를 설정해도 `ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 +`ADMIN.HMM_LEAVE_BALANCES`와 `ADMIN.HMM_LEAVE_REQUESTS`의 VPD 정책이 +실제 조회에 적용되지 않는다. + +컨텍스트 값이 올바른 것과 VPD가 적용되는 것은 별개의 조건이다. 보호 테이블을 +읽는 최종 SQL은 반드시 `EXEMPT ACCESS POLICY`가 없는 계정의 동일 DB 세션에서 +컨텍스트 설정과 함께 실행해야 한다. + +## 목표 + +- `ADMIN`은 Select AI `SHOWSQL` 생성만 담당한다. +- 비면제 런타임 스키마 `CB_ORDS`가 생성 SQL을 읽기 전용으로 실행한다. +- 실행 직전 같은 `CB_ORDS` 세션에서 + `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다. +- 실행 종료 시 성공·실패와 관계없이 `CLEAR_VPD_CONTEXT`를 호출하고 롤백한다. +- 런타임 계정이 `ADMIN`이거나 `EXEMPT ACCESS POLICY`를 가진 경우 fail-closed 한다. +- MCP `search_hr_data`만 새 경계로 전환하며 용어·정책 검색 도구는 기존 경로를 유지한다. + +## 구조 + +```text +MCP tools/call + └─ bearer token 해시 인증 + ├─ ADMIN / DBMS_CLOUD_AI.GENERATE(..., 'showsql') + │ └─ 허용된 HMM HR 객체만 포함한 SELECT/WITH 생성 + └─ CB_ORDS JDBC session + ├─ CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ...') + │ └─ ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(token) + ├─ runtime principal/컨텍스트 일치 검증 + ├─ SET TRANSACTION READ ONLY + ├─ 생성 SQL 실행 → HMM_LEAVE_SCOPE_POLICY 적용 + ├─ ROLLBACK + └─ CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT +``` + +## DB 설계 + +### 런타임 스키마 + +`CB_ORDS`에는 다음 최소 권한만 부여한다. + +- `CREATE SESSION` +- `ADMIN.CB_ORDS_HANDLER_PKG` 실행 +- Select AI 프로필의 승인 객체 6개에 대한 `SELECT` + - `HMM_ORG_TEAMS` + - `HMM_HR_EMPLOYEES` + - `HMM_LEAVE_BALANCES` + - `HMM_LEAVE_REQUESTS` + - `HMM_ATTENDANCE_DAILY` + - `HMM_HR_TERMS` + +`EXEMPT ACCESS POLICY`, `SELECT ANY TABLE`, 객체 생성 `ANY` 권한은 부여하지 않는다. +생성 SQL의 스키마 한정 여부에 영향을 받지 않도록 승인 객체에 한해 +`CB_ORDS` private synonym을 만든다. + +### 컨텍스트 패키지 + +`ADMIN.CB_ORDS_HANDLER_PKG`는 HTTP Authorization 값에서 Bearer 토큰을 +추출한 후 `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 호출한다. +토큰 원문은 테이블이나 로그에 저장하지 않는다. + +`CB_ORDS`에는 동일 이름의 private synonym만 제공하여 런타임 SQL에서는 +`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`로 호출한다. 컨텍스트 패키지 자체의 +직접 실행 권한은 런타임 계정에 노출하지 않는다. + +`CLEAR_VPD_CONTEXT`는 HMM 컨텍스트와 client identifier를 모두 정리한다. +유효하지 않은 인증 헤더는 컨텍스트를 먼저 지운 뒤 오류로 종료한다. + +## 애플리케이션 설계 + +`backoffice.select-ai` 설정을 두 연결로 분리한다. + +- 생성 연결: 기존 `db-url`, `db-username`, `db-password`, `profile` +- 실행 연결: `runtime-db-url`, `runtime-db-username`, `runtime-db-password` + +생성 연결은 기존 `BACKOFFICE_DB_*`를 기본값으로 사용할 수 있다. 실행 연결은 +명시적으로 설정해야 하며 생성 연결로 자동 폴백하지 않는다. + +실행 전 다음을 검증한다. + +1. `USER`가 `ADMIN`이 아니다. +2. `SESSION_PRIVS`에 `EXEMPT ACCESS POLICY`가 없다. +3. 패키지 호출 후 `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치한다. + +하나라도 실패하면 SQL을 실행하지 않는다. + +## 운영 설정 + +`search_hr_data`의 `executionType`을 `AGENT_TOOL`에서 `SELECT_AI`로 변경한다. +다른 두 도구는 그대로 유지한다. + +필수 환경값: + +```properties +BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE +BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= +BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS +BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD= +``` + +비밀번호는 Git·Redmine·로그에 기록하지 않고 서버 환경 파일에서만 관리한다. + +## 검증 기준 + +1. `CB_ORDS`에 `EXEMPT ACCESS POLICY`가 없음을 확인한다. +2. E1006 토큰으로 `HMM_LEAVE_BALANCES` 조회 결과의 직원은 E1006 한 명뿐이다. +3. E1006 토큰으로 `HMM_LEAVE_REQUESTS` 조회 결과에 E1002가 없다. +4. E1006이 E1002 휴가를 직접 요청하면 0건을 반환한다. +5. 관리자 E1001은 권한 규칙에 따라 자기 자신과 직속 팀원 범위를 조회한다. +6. 무효 토큰, 컨텍스트 불일치, 런타임 권한 오설정은 fail-closed 한다. + +## 롤백 + +- 애플리케이션 환경의 `search_hr_data`를 이전 `AGENT_TOOL` 정의로 되돌리고 + 이전 JAR를 재기동한다. +- `CB_ORDS` 스키마는 즉시 삭제하지 않고 계정을 잠가 조사 가능 상태로 보존한다. +- VPD 정책 자체와 기존 `HMM_ACCESS_CTX_PKG`는 변경하지 않는다. diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md index 034da2f..83bffc1 100644 --- a/docs/runbooks/hmm-mcp-token-configuration.md +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -125,16 +125,35 @@ MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token 1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다. 2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다. -3. 검증된 같은 token으로 DB 연결에서 - `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다. -4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. -5. `finally`에서 context를 초기화하고 connection pool에 반환한다. -6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 +3. `ADMIN` 연결은 Select AI `SHOWSQL` 생성까지만 수행한다. +4. `EXEMPT ACCESS POLICY`가 없는 `CB_ORDS` 연결에서 + `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ' || :token)`을 호출한다. +5. 같은 `CB_ORDS` DB 세션에서 생성된 읽기 전용 SQL로 + `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. +6. 실행 전 런타임 사용자가 `ADMIN`이 아니고 `EXEMPT ACCESS POLICY`가 없으며, + `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치하는지 확인한다. +7. 하나라도 다르면 조회하지 않고 fail-closed 한다. +8. `finally`에서 `CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT`와 rollback을 수행한다. +9. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 application identity와 user subject를 하나의 검증 가능한 access token으로 합친다. -이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직 -상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` -후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다. +이 흐름은 2026-07-31 백오피스 MCP `search_hr_data`에 적용됐다. 모든 MCP method가 토큰 +해시·만료·회수·재직 상태를 확인한다. `search_hr_data`는 +`HMM_HR_DATA_GPT54_PROFILE`로 SQL만 생성하고, 실제 SQL은 위 `CB_ORDS` 세션에서 실행한다. +무토큰·무효·회수 토큰은 HTTP 401이다. + +운영 환경에는 다음 값이 필요하다. + +```dotenv +BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE +BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= +BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS +BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<운영 secret> +``` + +DB 구성 스크립트는 `sql/adb/73_hmm_mcp_vpd_runtime.sql`이다. 이 스크립트는 승인된 HMM +HR 객체에 대한 개별 `SELECT`와 handler package 실행 권한만 부여하며 +`EXEMPT ACCESS POLICY`는 부여하지 않는다. 포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다. Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다. diff --git a/sql/adb/73_hmm_mcp_vpd_runtime.sql b/sql/adb/73_hmm_mcp_vpd_runtime.sql new file mode 100644 index 0000000..1d8248a --- /dev/null +++ b/sql/adb/73_hmm_mcp_vpd_runtime.sql @@ -0,0 +1,159 @@ +-- #740 HMM MCP VPD execution boundary +-- Run as ADMIN with SQLcl. The password is prompted without echo and is used +-- only when CB_ORDS does not already exist. +set define on verify off echo off feedback on serveroutput on +whenever sqlerror exit sql.sqlcode rollback + +accept hmm_cb_ords_password char prompt 'CB_ORDS initial password: ' hide + +declare + v_user_count number; + v_password varchar2(4000) := q'~&hmm_cb_ords_password~'; +begin + select count(*) + into v_user_count + from dba_users + where username = 'CB_ORDS'; + + if v_user_count = 0 then + if length(v_password) < 16 + or instr(v_password, '"') > 0 + or instr(v_password, chr(10)) > 0 + or instr(v_password, chr(13)) > 0 + or not regexp_like(v_password, '[A-Z]') + or not regexp_like(v_password, '[a-z]') + or not regexp_like(v_password, '[0-9]') + or not regexp_like(v_password, '[^A-Za-z0-9]') then + raise_application_error( + -20001, + 'CB_ORDS password must be 16+ chars with upper/lower/digit/special and no quote/newline.'); + end if; + execute immediate + 'create user CB_ORDS identified by "' || v_password || '" ' + || 'default tablespace DATA temporary tablespace TEMP quota 0 on DATA'; + dbms_output.put_line('Created CB_ORDS.'); + else + dbms_output.put_line('CB_ORDS already exists; password was not changed.'); + end if; +end; +/ + +alter user CB_ORDS account unlock; +grant create session to CB_ORDS; + +create or replace package CB_ORDS_HANDLER_PKG +authid definer +as + procedure SET_VPD_CONTEXT(p_authorization in varchar2); + procedure CLEAR_VPD_CONTEXT; +end CB_ORDS_HANDLER_PKG; +/ + +create or replace package body CB_ORDS_HANDLER_PKG +as + c_bearer_prefix constant varchar2(7) := 'Bearer '; + + procedure CLEAR_VPD_CONTEXT + is + begin + HMM_ACCESS_CTX_PKG.CLEAR_USER; + dbms_session.clear_identifier; + end CLEAR_VPD_CONTEXT; + + procedure SET_VPD_CONTEXT(p_authorization in varchar2) + is + v_authorization varchar2(2048) := trim(p_authorization); + v_token varchar2(512); + begin + CLEAR_VPD_CONTEXT; + if v_authorization is null + or length(v_authorization) > 2048 + or upper(substr(v_authorization, 1, length(c_bearer_prefix))) + <> upper(c_bearer_prefix) then + raise_application_error(-20002, 'A valid Bearer authorization header is required.'); + end if; + + v_token := trim(substr(v_authorization, length(c_bearer_prefix) + 1)); + if v_token is null or length(v_token) > 512 then + raise_application_error(-20003, 'A valid Bearer token is required.'); + end if; + + HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(v_token); + dbms_session.set_identifier( + sys_context('HMM_ACCESS_CTX', 'EMPLOYEE_CODE')); + exception + when others then + CLEAR_VPD_CONTEXT; + raise; + end SET_VPD_CONTEXT; +end CB_ORDS_HANDLER_PKG; +/ + +grant execute on CB_ORDS_HANDLER_PKG to CB_ORDS; + +grant select on HMM_ORG_TEAMS to CB_ORDS; +grant select on HMM_HR_EMPLOYEES to CB_ORDS; +grant select on HMM_LEAVE_BALANCES to CB_ORDS; +grant select on HMM_LEAVE_REQUESTS to CB_ORDS; +grant select on HMM_ATTENDANCE_DAILY to CB_ORDS; +grant select on HMM_HR_TERMS to CB_ORDS; + +create or replace synonym CB_ORDS.CB_ORDS_HANDLER_PKG + for ADMIN.CB_ORDS_HANDLER_PKG; +create or replace synonym CB_ORDS.HMM_ORG_TEAMS + for ADMIN.HMM_ORG_TEAMS; +create or replace synonym CB_ORDS.HMM_HR_EMPLOYEES + for ADMIN.HMM_HR_EMPLOYEES; +create or replace synonym CB_ORDS.HMM_LEAVE_BALANCES + for ADMIN.HMM_LEAVE_BALANCES; +create or replace synonym CB_ORDS.HMM_LEAVE_REQUESTS + for ADMIN.HMM_LEAVE_REQUESTS; +create or replace synonym CB_ORDS.HMM_ATTENDANCE_DAILY + for ADMIN.HMM_ATTENDANCE_DAILY; +create or replace synonym CB_ORDS.HMM_HR_TERMS + for ADMIN.HMM_HR_TERMS; + +declare + v_exempt_count number; + v_policy_count number; +begin + select count(*) + into v_exempt_count + from dba_sys_privs + where grantee = 'CB_ORDS' + and privilege = 'EXEMPT ACCESS POLICY'; + if v_exempt_count <> 0 then + raise_application_error(-20004, 'CB_ORDS must not have EXEMPT ACCESS POLICY.'); + end if; + + select count(*) + into v_policy_count + from dba_policies + where object_owner = 'ADMIN' + and object_name in ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') + and policy_name = 'HMM_LEAVE_SCOPE_POLICY' + and enable = 'YES' + and sel = 'YES'; + if v_policy_count <> 2 then + raise_application_error(-20005, 'Both HMM leave VPD policies must be enabled.'); + end if; +end; +/ + +select username, account_status + from dba_users + where username = 'CB_ORDS'; + +select privilege + from dba_sys_privs + where grantee = 'CB_ORDS' + order by privilege; + +select object_name, object_type, status + from dba_objects + where owner = 'ADMIN' + and object_name = 'CB_ORDS_HANDLER_PKG' + order by object_type; + +undefine hmm_cb_ords_password +prompt HMM MCP VPD runtime boundary is ready. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index c980efc..e5edcd2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -79,7 +79,10 @@ public record BackofficeProperties( String dbUrl, String dbUsername, String dbPassword, - String profile + String profile, + String runtimeDbUrl, + String runtimeDbUsername, + String runtimeDbPassword ) { public boolean configured() { @@ -88,5 +91,12 @@ public record BackofficeProperties( && dbPassword != null && !dbPassword.isBlank() && profile != null && !profile.isBlank(); } + + /** The generated SQL must never fall back to the privileged profile-owner connection. */ + public boolean runtimeConfigured() { + return runtimeDbUrl != null && !runtimeDbUrl.isBlank() + && runtimeDbUsername != null && !runtimeDbUsername.isBlank() + && runtimeDbPassword != null && !runtimeDbPassword.isBlank(); + } } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index e7ae37a..ef5c239 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -14,6 +14,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.Statement; +import java.util.Locale; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -45,7 +46,7 @@ public class SelectAiService { } public JsonNode generateAndExecute(String bearerToken, String prompt) { - bearerAuthenticator.authenticate(bearerToken); + HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); @@ -54,11 +55,18 @@ public class SelectAiService { + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요."); } + if (!selectAi.runtimeConfigured()) { + throw new AppException("VPD 런타임 연결 설정이 필요합니다. " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_URL, " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME, " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD를 확인하세요."); + } - String generatedSql = generate(selectAi, normalizedPrompt); + String generatedSql = generate( + selectAi, vpdAwarePrompt(normalizedPrompt, principal)); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly( - selectAi, normalizedSql, bearerToken.trim()); + selectAi, normalizedSql, bearerToken.trim(), principal); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); @@ -66,6 +74,8 @@ public class SelectAiService { response.put("execution", "READ_ONLY_EXECUTED"); response.put("rowCount", execution.items().size()); response.put("truncated", execution.truncated()); + response.put("vpdEnforced", true); + response.put("scopeEmployeeCode", principal.employeeCode()); response.set("items", execution.items()); response.put("nextStep", execution.truncated() ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다." @@ -84,6 +94,18 @@ public class SelectAiService { return normalized; } + private String vpdAwarePrompt(String prompt, HmmMcpPrincipal principal) { + return """ + Oracle SQL 생성 규칙: + - 프로필에 승인된 HMM HR 객체만 사용하세요. + - 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요. + - 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요. + - 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요. + + 사용자 질문: %s + """.formatted(principal.employeeCode(), prompt); + } + private String generate( BackofficeProperties.SelectAi selectAi, String prompt @@ -110,20 +132,25 @@ public class SelectAiService { private QueryExecution executeReadOnly( BackofficeProperties.SelectAi selectAi, String generatedSql, - String bearerToken + String bearerToken, + HmmMcpPrincipal principal ) { ArrayNode items = objectMapper.createArrayNode(); boolean truncated = false; try (Connection connection = DriverManager.getConnection( - selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) { + selectAi.runtimeDbUrl(), + selectAi.runtimeDbUsername(), + selectAi.runtimeDbPassword())) { + verifyNonExemptRuntime(connection); boolean contextSet = false; try { try (CallableStatement statement = connection.prepareCall( - "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) { - statement.setString(1, bearerToken); + "BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) { + statement.setString(1, "Bearer " + bearerToken); statement.execute(); contextSet = true; } + verifyVpdContext(connection, principal); connection.setAutoCommit(false); connection.setReadOnly(true); try (Statement transaction = connection.createStatement()) { @@ -148,7 +175,7 @@ public class SelectAiService { if (column == null || column.isBlank()) { column = metadata.getColumnName(columnIndex); } - putResultValue(row, column, resultSet.getObject(columnIndex)); + putResultValue(row, column, resultSet, columnIndex); } } } @@ -159,7 +186,7 @@ public class SelectAiService { } finally { if (contextSet) { try (CallableStatement statement = connection.prepareCall( - "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + "BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) { statement.execute(); } } @@ -171,7 +198,53 @@ public class SelectAiService { return new QueryExecution(items, truncated); } - private void putResultValue(ObjectNode row, String column, Object value) { + private void verifyNonExemptRuntime(Connection connection) throws Exception { + String runtimeUser; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT USER FROM dual")) { + if (!resultSet.next()) { + throw new AppException("VPD 런타임 DB 사용자를 확인할 수 없습니다."); + } + runtimeUser = resultSet.getString(1); + } + if (runtimeUser == null || "ADMIN".equals(runtimeUser.toUpperCase(Locale.ROOT))) { + throw new AppException("VPD 런타임 연결은 ADMIN을 사용할 수 없습니다."); + } + try (PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM SESSION_PRIVS WHERE PRIVILEGE = ?")) { + statement.setString(1, "EXEMPT ACCESS POLICY"); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next() || resultSet.getInt(1) != 0) { + throw new AppException( + "VPD 런타임 계정에 EXEMPT ACCESS POLICY가 있어 실행을 차단했습니다."); + } + } + } + } + + private void verifyVpdContext( + Connection connection, + HmmMcpPrincipal principal + ) throws Exception { + String sql = "SELECT SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE') FROM dual"; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + String employeeCode = + resultSet.next() ? resultSet.getString(1) : null; + if (employeeCode == null + || !employeeCode.equalsIgnoreCase(principal.employeeCode())) { + throw new AppException("Bearer Token 사용자와 VPD 세션 컨텍스트가 일치하지 않습니다."); + } + } + } + + private void putResultValue( + ObjectNode row, + String column, + ResultSet resultSet, + int columnIndex + ) throws Exception { + Object value = resultSet.getObject(columnIndex); if (value == null) { row.putNull(column); } else if (value instanceof BigDecimal number) { @@ -191,7 +264,9 @@ public class SelectAiService { } else if (value instanceof Boolean bool) { row.put(column, bool); } else { - row.put(column, String.valueOf(value)); + // Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as + // oracle.sql.TIMESTAMPTZ@, which is not usable MCP evidence. + row.put(column, resultSet.getString(columnIndex)); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 324b37b..16f5d20 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,10 +69,15 @@ backoffice: oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} select-ai: - db-url: ${BACKOFFICE_SELECT_AI_DB_URL:} - db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:} - db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:} + # Profile-owner connection: SHOWSQL generation only. + db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}} + db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:${BACKOFFICE_DB_USERNAME:}} + db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:${BACKOFFICE_DB_PASSWORD:}} profile: ${BACKOFFICE_SELECT_AI_PROFILE:} + # Non-EXEMPT execution boundary: no fallback to the profile owner is allowed. + runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:} + runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:} + runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:} catalog: owner: ${BACKOFFICE_CATALOG_OWNER:} objects: ${BACKOFFICE_CATALOG_OBJECTS:} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 121914b..d08dc3c 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -3,6 +3,8 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.config.McpProperties; @@ -106,6 +108,50 @@ class McpSseServiceTest { assertThat(response.path("result").path("isError").asBoolean()).isFalse(); } + @Test + void routesSelectAiToolThroughTheVpdAwareExecutor() { + String selectAiTools = """ + [ + { + "name":"search_hr_data", + "label":"HMM HR 데이터 조회", + "description":"VPD가 적용된 HMM HR 데이터를 조회합니다.", + "argumentName":"query", + "argumentDescription":"완전한 자연어 질문입니다.", + "executionType":"SELECT_AI" + } + ] + """; + McpProperties selectAiProperties = + new McpProperties("", "", "", "", "", "", selectAiTools); + SelectAiService selectAiService = mock(SelectAiService.class); + when(selectAiService.generateAndExecute( + "valid-token", "내 휴가 신청 내역을 보여줘")) + .thenReturn(objectMapper.createObjectNode().put("vpdEnforced", true)); + McpSseService selectAiMcp = new McpSseService( + agentToolRunner, + selectAiService, + bearerAuthenticator, + new EnvironmentMcpToolCatalog(selectAiProperties, objectMapper), + selectAiProperties, + new BackofficeProperties(null, null, null, null, null), + objectMapper); + ObjectNode request = request(5, "tools/call"); + request.putObject("params") + .put("name", "search_hr_data") + .putObject("arguments") + .put("query", "내 휴가 신청 내역을 보여줘"); + + ObjectNode response = + selectAiMcp.handle("default", request, "valid-token"); + + verify(selectAiService).generateAndExecute( + "valid-token", "내 휴가 신청 내역을 보여줘"); + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + assertThat(response.path("result").path("content").get(0).path("text").asText()) + .contains("\"vpdEnforced\" : true"); + } + @Test void rejectsDiscoveryWhenBearerAuthenticationFails() { McpSseService rejectingService = new McpSseService( From e9d50e6a32a84000bae24bc2c11b981999376170 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 3 Aug 2026 10:20:36 +0900 Subject: [PATCH 18/18] refs #741 #742: reorganize repository by application --- .env.example | 8 +- .gitignore | 6 + README.md | 52 +- ai-web-agent-console/.env.example | 48 + ai-web-agent-console/README.md | 57 + .../ai_web_agent_console}/__init__.py | 0 .../ai_web_agent_console}/audit.py | 0 .../ai_web_agent_console}/auth_gateway.py | 32 +- .../ai_web_agent_console}/mcp_result.py | 0 .../ai_web_agent_console}/mcp_tool_router.py | 4 +- .../ai_web_agent_console}/model_registry.py | 84 +- .../ai_web_agent_console}/oci_genai_sdk.py | 0 .../ai_web_agent_console}/presentation.py | 0 .../ai_web_agent_console}/profile.py | 0 .../ai_web_agent_console/query_contracts.py | 407 +++++ .../ai_web_agent_console}/questions.py | 6 +- .../ai_web_agent_console}/scenarios.py | 0 .../app.py | 1586 ++--------------- .../config/app_profile.json | 0 .../config/hmm_demo_scenarios.json | 0 .../config/hmm_hr_query_contracts.json | 84 + .../config/mcp_servers.json | 2 +- .../config/model_profiles.json | 12 +- .../config/vpd_token_presets.json | 0 .../requirements-langgraph.txt | 0 .../requirements.txt | 0 .../tests/test_auth_gateway.py | 2 +- .../tests/test_query_contracts.py | 181 ++ .../tests/test_scenarios.py | 37 +- {sql => database}/adb/00_cleanup.sql | 0 {sql => database}/adb/01_dblinks.sql | 0 {sql => database}/adb/02_perm_tables.sql | 0 {sql => database}/adb/03_seed.sql | 2 +- {sql => database}/adb/04_secure_ctx.sql | 0 {sql => database}/adb/05_views.sql | 0 {sql => database}/adb/06_policy.sql | 0 {sql => database}/adb/06a_redaction.sql | 0 {sql => database}/adb/07_end_users.sql | 0 {sql => database}/adb/08_tests_user_my.sql | 0 {sql => database}/adb/09_tests_user_pg.sql | 0 {sql => database}/adb/10_tests_user_both.sql | 0 {sql => database}/adb/11_tests_user_none.sql | 0 .../adb/12_tests_admin_audit.sql | 0 {sql => database}/adb/13_dds_variant.sql | 0 {sql => database}/adb/14_tests_dds_user.sql | 0 {sql => database}/adb/15_dds_cleanup.sql | 0 .../16_agent_ords_security_local_cleanup.sql | 0 ...17_agent_ords_security_local_vpd_setup.sql | 0 .../18_agent_ords_security_local_vpd_test.sql | 0 ...19_agent_ords_security_local_dds_setup.sql | 0 .../20_agent_ords_security_local_dds_test.sql | 0 ...agent_ords_security_ords_enable_schema.sql | 0 ...agent_ords_security_ords_handler_setup.sql | 0 ..._agent_ords_security_ords_handler_test.sql | 0 .../adb/24_agent_ords_security_inventory.sql | 0 ...agent_ords_security_backoffice_support.sql | 0 ...agent_ords_security_dynamic_vpd_filter.sql | 0 ..._ords_security_dynamic_vpd_filter_test.sql | 2 +- .../28_agent_ords_vector_tag_vpd_setup.sql | 0 .../adb/29_agent_ords_vector_search_ords.sql | 0 .../adb/30_agent_ords_vector_tag_vpd_test.sql | 0 .../31_agent_ords_backoffice_ux_metadata.sql | 0 .../adb/31_dds_standalone_demo_setup.sql | 0 .../adb/32_dds_vector_tag_setup.sql | 0 .../adb/33_agent_ords_sql_trace_grant.sql | 0 ...34_agent_ords_execution_evidence_grant.sql | 0 .../34_dds_token_data_grant_common_auth.sql | 0 .../adb/35_dds_token_data_grant_cleanup.sql | 0 .../adb/36_dds_sales_knowledge_scenario.sql | 0 ...7_dds_sales_knowledge_scenario_cleanup.sql | 0 ...ds_vector_all_permission_normalization.sql | 0 ...9_agent_ords_default_user_display_name.sql | 0 .../adb/40_dds_protection_evidence.sql | 0 .../adb/41_dds_row_column_fixture.sql | 0 .../adb/42_agent_ords_fga_execution_audit.sql | 0 .../adb/42_dds_fga_execution_evidence.sql | 0 .../43_agent_ords_probe_id_handler_patch.sql | 0 .../adb/44_dds_mcp_local_end_user_setup.sql | 0 .../adb/45_dds_mcp_local_end_user_test.sql | 0 ...b_stakeholder_token_permission_example.sql | 0 ..._kb_stakeholder_permission_or_branches.sql | 0 .../adb/48_kb_permission_condition_codes.sql | 0 .../49_permission_static_sql_condition.sql | 0 .../adb/51_kb_vpd_admin_token.sql | 0 .../adb/52_cleanup_to_kb_vpd_poc.sql | 0 .../adb/53_drop_legacy_poc_objects.sql | 0 .../adb/54_drop_legacy_dds_ords_examples.sql | 0 .../55_reapply_kb_vpd_after_table_reload.sql | 0 .../adb/56_kb_select_ai_router_team.sql | 0 .../adb/57_kb_select_ai_router_team_test.sql | 0 .../adb/58_kb_select_ai_router_api.sql | 0 .../adb/59_kb_select_ai_router_ords.sql | 0 .../adb/60_kb_vpd_admin_full_access_token.sql | 0 .../62_kb_aso_masking_backoffice_metadata.sql | 0 .../adb/63_kb_aso_masking_rule_runtime.sql | 0 ...64_kb_aso_masking_default_column_rules.sql | 0 .../adb/65_kb_select_ai_vpd_query_api.sql | 0 .../adb/66_kb_select_ai_vpd_query_ords.sql | 0 ...cy_kb_contracts_premium_vpd_cls_policy.sql | 0 {sql => database}/adb/70_sg_tool_user.sql | 0 .../adb/71_sg_identity_administration.sql | 0 .../adb/72_hmm_leave_team_vpd.sql | 0 .../adb/73_hmm_mcp_vpd_runtime.sql | 0 .../adb/73_hmm_rds_pg_00_precheck.sql | 0 .../adb/74_hmm_rds_pg_01_credential.sql | 0 .../adb/75_hmm_rds_pg_02_acl.sql | 0 .../adb/76_hmm_rds_pg_03_dblink.sql | 0 .../adb/77_hmm_rds_pg_04_remote_query.sql | 0 .../adb/78_hmm_rds_pg_05_views.sql | 0 .../adb/79_hmm_rds_pg_06_verify.sql | 0 .../adb/80_hmm_rds_pg_99_cleanup.sql | 0 .../adb/81_hmm_carrier_access_groups.sql | 0 ...2_hmm_federation_relationship_comments.sql | 0 {sql => database}/source/mysql_setup.sql | 4 +- .../postgres_hmm_carrier_performance.sql | 4 +- ...ostgres_hmm_carrier_performance_verify.sql | 0 {sql => database}/source/postgres_setup.sql | 4 +- dds-backoffice/README.md | 8 +- .../nginx/hmm-portal-auth.locations.conf | 0 .../systemd/ai-web-agent-console-audit.conf | 0 .../systemd/hmm-portal-auth.service | 2 +- .../backoffice.env.example} | 0 .../caddy/Caddyfile.template | 0 .../caddy/install-caddy-config.sh | 0 .../vpd-backoffice-structured-data.conf | 0 docs/03-detailed-guide.md | 4 +- docs/04-source-setup.md | 10 +- docs/05-dds-variant.md | 6 +- docs/06-agent-ords-vpd-dds-security-brief.md | 20 +- .../424-spring-boot-vpd-backoffice/README.md | 12 +- .../456-vpd-predicate-rule-tests/README.md | 6 +- .../fn-cb_agent_doc_vpd_filter.md | 2 +- .../462-predicate-injection-defense/README.md | 6 +- docs/design/547-vm-https-hardening/README.md | 4 +- .../559-independent-vpd-dds-demos/README.md | 2 +- docs/design/567-ords-vpd-sql-trace/README.md | 4 +- .../fn-addLocalSqlTrace.md | 2 +- .../fn-findVpdPredicate.md | 2 +- .../617-dds-mcp-end-user-context/README.md | 4 +- .../README.md | 51 +- .../701-hmm-ai-agent-modularization/README.md | 19 +- .../702-hmm-backoffice-identity/README.md | 2 +- docs/design/707-hmm-leave-team-vpd/README.md | 2 +- .../723-hmm-configurable-backoffice/README.md | 2 +- .../README.md | 4 +- .../README.md | 112 ++ docs/notes/2026-06-08-dds-qa-session.md | 6 +- .../notes/hmm-brand-refresh.md | 3 +- docs/reference/ords-vpd-sql-trace.md | 4 +- ...026-07-10-existing-01-mcp-rag-diagnosis.md | 2 +- ...10-select-ai-profile-performance-report.md | 8 +- ...6-07-23-hmm-leave-team-vpd-verification.md | 2 +- ...026-07-23-hmm-portal-full-browser-audit.md | 2 +- docs/runbooks/460-sqlcl-vpd-deploy-runbook.md | 6 +- docs/runbooks/hmm-mcp-token-configuration.md | 2 +- poc4_active_source_20260714/.env.sample | 131 -- poc4_active_source_20260714/SOURCE_README.md | 65 - .../poc4_active_source_20260714.tar.gz | Bin 74102 -> 0 bytes .../poc4/start_8622_langgraph_tc_ui_nohup.sh | 4 - .../poc4/status_8622_langgraph_tc_ui_nohup.sh | 4 - .../src/poc4/__init__.py | 1 - run.sh | 50 +- scripts/configure-backoffice-https-vm.sh | 4 +- scripts/deploy-backoffice-vm.sh | 8 +- scripts/deploy-dds-backoffice-local.sh | 6 +- scripts/provision-dds-token-search.sh | 4 +- .../run-dds-mcp-local-end-user-scenario.sh | 4 +- scripts/run_agent_ords_security_adb_local.sh | 24 +- scripts/setup-dds-protection-evidence.sh | 2 +- scripts/setup-dds-token-data-grant.sh | 2 +- tests/README.md | 11 + tests/test_repository_layout.py | 58 + pom.xml => vpd-backoffice/pom.xml | 6 +- .../VpdBackofficeApplication.java | 0 .../vpdbackoffice/config/AppConfig.java | 0 .../config/BackofficeProperties.java | 3 +- .../config/CatalogProperties.java | 0 .../vpdbackoffice/config/DbPoolWarmup.java | 0 .../config/MaskingProperties.java | 0 .../vpdbackoffice/config/McpProperties.java | 0 .../config/OrdsClientConfig.java | 0 .../config/ProductProperties.java | 0 .../vpdbackoffice/config/SecurityConfig.java | 0 .../config/SecuritySqlScriptProperties.java | 0 .../domain/audit/AuditEvent.java | 0 .../domain/effective/EffectiveMatrixView.java | 0 .../effective/GroupEffectiveAccessView.java | 0 .../effective/RoleEffectiveImpactView.java | 0 .../effective/UserEffectiveAccessView.java | 0 .../vpdbackoffice/domain/group/AppGroup.java | 0 .../domain/group/GroupCreateCommand.java | 0 .../domain/group/GroupRoleView.java | 0 .../domain/group/GroupUserView.java | 0 .../domain/masking/ColumnMaskingRule.java | 0 .../domain/masking/MaskingPolicyStatus.java | 0 .../domain/masking/MaskingRule.java | 0 .../masking/MaskingRuleCreateCommand.java | 0 .../domain/masking/MaskingTemplate.java | 0 .../domain/masking/UserMaskingRule.java | 0 .../domain/mcp/McpChatbotResult.java | 0 .../domain/mcp/McpClientDemoResult.java | 0 .../domain/mcp/McpReasoningCommand.java | 0 .../domain/mcp/McpReasoningResult.java | 0 .../vpdbackoffice/domain/mcp/McpToolView.java | 0 .../operation/OperationHealthSummary.java | 0 .../domain/operation/OperationStatusRow.java | 0 .../domain/ords/OrdsHandlerUpdateCommand.java | 0 .../domain/ords/OrdsHandlerView.java | 0 .../domain/ords/OrdsObjectHandlerResult.java | 0 .../domain/permission/AppRole.java | 0 .../domain/permission/PermissionRule.java | 0 .../domain/permission/PermissionSet.java | 0 .../permission/PermissionSetCommand.java | 0 .../domain/permission/PermissionView.java | 0 .../domain/permission/RuleCommand.java | 0 .../domain/probe/FgaExecutionEvidence.java | 0 .../domain/probe/ProbeCommand.java | 0 .../domain/probe/ProbeResult.java | 0 .../domain/probe/ProbeStatus.java | 0 .../domain/probe/SqlExecutionCandidate.java | 0 .../domain/probe/SqlExecutionEvidence.java | 0 .../protectedobject/DatabaseObjectOption.java | 0 .../protectedobject/ProtectedColumn.java | 0 .../protectedobject/ProtectedObject.java | 0 .../ProtectedObjectCreateCommand.java | 0 .../domain/schema/SchemaActionResult.java | 0 .../domain/schema/SchemaPreflightCheck.java | 0 .../domain/schema/SchemaPreflightView.java | 0 .../schemametadata/SchemaAnnotation.java | 0 .../schemametadata/SchemaMetadataColumn.java | 0 .../schemametadata/SchemaMetadataView.java | 0 .../securityscript/SecuritySqlScript.java | 0 .../SecuritySqlScriptExplanation.java | 0 .../SecuritySqlScriptSummary.java | 0 .../domain/setting/BackofficeSetting.java | 0 .../stakeholder/StakeholderTokenSubject.java | 0 .../structured/StructuredDataPreview.java | 0 .../structured/StructuredDataTable.java | 0 .../domain/token/BearerTokenRecord.java | 0 .../domain/token/IssuedToken.java | 0 .../domain/token/TokenContextView.java | 0 .../domain/token/TokenIssueCommand.java | 0 .../vpdbackoffice/domain/user/AppUser.java | 0 .../domain/user/UserCreateCommand.java | 0 .../domain/user/UserRoleView.java | 0 .../domain/vector/VectorChunk.java | 0 .../domain/vector/VectorIngestCommand.java | 0 .../domain/vector/VectorIngestResult.java | 0 .../domain/vector/VectorKnowledgeSummary.java | 0 .../domain/vector/VectorQueryEmbedding.java | 0 .../domain/vector/VectorSearchResult.java | 0 .../domain/vpd/VpdBulkApplyResult.java | 0 .../domain/vpd/VpdDescriptionNote.java | 0 .../domain/vpd/VpdFunctionOption.java | 0 .../domain/vpd/VpdFunctionSource.java | 0 .../domain/vpd/VpdObjectFilterDetail.java | 0 .../domain/vpd/VpdPolicyCreateCommand.java | 0 .../domain/vpd/VpdPolicyDetail.java | 0 .../domain/vpd/VpdPolicyExplanation.java | 0 .../domain/vpd/VpdPolicyFormOptions.java | 0 .../domain/vpd/VpdPolicyTemplateOption.java | 0 .../domain/vpd/VpdPolicyView.java | 0 .../domain/vpd/VpdSchemaObjectOption.java | 0 .../domain/vpd/VpdTargetView.java | 0 .../vpdbackoffice/mapper/AuditMapper.java | 0 .../mapper/BearerTokenMapper.java | 0 .../vpdbackoffice/mapper/GroupMapper.java | 0 .../mapper/MaskingRuleMapper.java | 0 .../mapper/OperationStatusMapper.java | 0 .../mapper/OrdsMetadataMapper.java | 0 .../mapper/PermissionMapper.java | 0 .../mapper/ProtectedObjectMapper.java | 0 .../vpdbackoffice/mapper/SettingMapper.java | 0 .../mapper/StakeholderMapper.java | 0 .../vpdbackoffice/mapper/UserMapper.java | 0 .../vpdbackoffice/mapper/VpdPolicyMapper.java | 0 .../vpdbackoffice/service/AppException.java | 0 .../vpdbackoffice/service/AuditService.java | 0 .../service/BackofficeSchemaService.java | 4 +- .../service/BearerTokenService.java | 0 .../vpdbackoffice/service/DataCatalog.java | 0 .../service/EffectiveMatrixService.java | 0 .../service/EnvironmentDataCatalog.java | 0 .../EnvironmentMaskingPolicyCatalog.java | 0 .../service/EnvironmentMcpToolCatalog.java | 0 .../ExternalAuthorizationChangeNotifier.java | 0 .../ExternalAuthorizationSynchronizer.java | 0 .../vpdbackoffice/service/GroupService.java | 0 .../service/HmmAiAgentToolRunner.java | 0 .../HmmKnowledgeSchemaInitializer.java | 0 .../service/HmmMcpBearerAuthenticator.java | 0 .../service/HmmMcpPrincipal.java | 0 .../HmmRuntimeSettingsInitializer.java | 0 .../service/JdbcHmmAiAgentToolRunner.java | 0 .../JdbcHmmMcpBearerAuthenticator.java | 0 .../service/MaskingPolicyCatalog.java | 0 .../service/MaskingPolicySynchronizer.java | 0 .../service/MaskingPolicyTarget.java | 0 .../service/MaskingRuleService.java | 0 .../service/McpChatbotService.java | 0 .../service/McpClientDemoService.java | 0 .../service/McpReasoningService.java | 0 .../vpdbackoffice/service/McpSseService.java | 0 .../vpdbackoffice/service/McpToolCatalog.java | 0 .../service/McpToolDefinition.java | 0 .../service/McpToolRegistry.java | 0 .../service/McpUnauthorizedException.java | 0 .../service/OciGenerativeAiChatClient.java | 0 .../service/OpenAiCompatibleClient.java | 0 .../service/OperationStatusService.java | 0 .../service/OrdsMetadataService.java | 0 .../service/OrdsProbeService.java | 0 .../service/PermissionService.java | 0 .../service/ProbeErrorClassifier.java | 0 .../service/ProtectedObjectService.java | 0 .../service/SchemaMetadataService.java | 0 .../SecuritySqlScriptExplanationService.java | 0 .../service/SecuritySqlScriptService.java | 4 +- .../service/SelectAiAgentOrdsService.java | 0 .../service/SelectAiService.java | 50 +- .../vpdbackoffice/service/SettingService.java | 0 .../service/StructuredDataService.java | 0 .../vpdbackoffice/service/TokenGenerator.java | 0 .../vpdbackoffice/service/TokenHasher.java | 0 .../vpdbackoffice/service/UserService.java | 0 .../vpdbackoffice/service/VectorChunker.java | 0 .../service/VectorKnowledgeService.java | 0 .../service/VpdPolicyService.java | 0 .../VpdTokenAccessDeniedException.java | 0 .../web/AppExceptionHandler.java | 0 .../web/DashboardController.java | 0 .../web/EffectiveMatrixController.java | 0 .../vpdbackoffice/web/GroupController.java | 0 .../vpdbackoffice/web/LoginController.java | 0 .../web/MaskingRuleController.java | 0 .../web/McpChatbotController.java | 0 .../web/McpClientDemoController.java | 0 .../web/McpReasoningController.java | 0 .../vpdbackoffice/web/McpSseController.java | 0 .../web/OperationStatusController.java | 0 .../web/OrdsHandlerController.java | 0 .../web/PermissionController.java | 0 .../vpdbackoffice/web/ProbeController.java | 0 .../vpdbackoffice/web/ProductModelAdvice.java | 0 .../web/ProtectedObjectController.java | 0 .../vpdbackoffice/web/RoleController.java | 0 .../web/RuntimeErrorMessage.java | 0 .../web/RuntimeErrorMessages.java | 0 .../web/SchemaMetadataController.java | 0 .../web/SecurityModelAdvice.java | 0 .../web/SecuritySqlScriptController.java | 0 .../vpdbackoffice/web/SettingController.java | 0 .../web/StructuredDataController.java | 0 .../vpdbackoffice/web/TokenController.java | 0 .../vpdbackoffice/web/UserController.java | 0 .../web/UserMaskingRuleController.java | 0 .../web/VectorKnowledgeController.java | 0 .../web/VpdPolicyController.java | 0 .../src}/main/resources/application.yml | 2 + .../main/resources/mapper/AuditMapper.xml | 0 .../resources/mapper/BearerTokenMapper.xml | 0 .../main/resources/mapper/GroupMapper.xml | 0 .../resources/mapper/MaskingRuleMapper.xml | 0 .../mapper/OperationStatusMapper.xml | 0 .../resources/mapper/OrdsMetadataMapper.xml | 0 .../resources/mapper/PermissionMapper.xml | 0 .../mapper/ProtectedObjectMapper.xml | 0 .../main/resources/mapper/SettingMapper.xml | 0 .../resources/mapper/StakeholderMapper.xml | 0 .../src}/main/resources/mapper/UserMapper.xml | 0 .../main/resources/mapper/VpdPolicyMapper.xml | 0 .../src}/main/resources/static/css/app.css | 0 .../src}/main/resources/static/js/app.js | 0 .../main/resources/templates/dashboard.html | 0 .../resources/templates/effective-matrix.html | 0 .../src}/main/resources/templates/error.html | 0 .../resources/templates/fragments/layout.html | 0 .../fragments/mcp-chatbot-result.html | 0 .../fragments/mcp-client-demo-result.html | 0 .../fragments/mcp-reasoning-result.html | 0 .../fragments/ords-handler-source.html | 0 .../templates/fragments/probe-result.html | 0 .../security-sql-script-explanation.html | 0 .../fragments/vector-search-result.html | 0 .../fragments/vpd-function-source.html | 0 .../fragments/vpd-object-filter-detail.html | 0 .../fragments/vpd-policy-detail.html | 0 .../fragments/vpd-policy-explanation.html | 0 .../src}/main/resources/templates/groups.html | 0 .../src}/main/resources/templates/login.html | 0 .../resources/templates/masking-rules.html | 0 .../main/resources/templates/mcp-chatbot.html | 0 .../resources/templates/mcp-client-demo.html | 0 .../resources/templates/mcp-reasoning.html | 0 .../main/resources/templates/mcp-sse.html | 0 .../main/resources/templates/objects.html | 0 .../resources/templates/operation-status.html | 0 .../resources/templates/ords-handlers.html | 0 .../main/resources/templates/permissions.html | 0 .../src}/main/resources/templates/probe.html | 0 .../src}/main/resources/templates/roles.html | 0 .../resources/templates/schema-metadata.html | 0 .../templates/security-sql-scripts.html | 2 +- .../templates/settings-database.html | 2 +- .../main/resources/templates/settings.html | 0 .../resources/templates/structured-data.html | 0 .../src}/main/resources/templates/tokens.html | 0 .../templates/user-masking-rules.html | 0 .../src}/main/resources/templates/users.html | 0 .../resources/templates/vector-knowledge.html | 0 .../templates/vpd-filter-policies.html | 0 .../templates/vpd-filter-runtime.html | 0 .../resources/templates/vpd-policies.html | 0 .../config/TransportSecurityTest.java | 0 .../domain/masking/MaskingTemplateTest.java | 0 .../domain/permission/PermissionViewTest.java | 0 .../domain/probe/ProbeResultTest.java | 0 .../protectedobject/ProtectedColumnTest.java | 0 .../domain/vpd/VpdPolicyFormOptionsTest.java | 0 .../service/EffectiveMatrixServiceTest.java | 0 ...ternalAuthorizationChangeNotifierTest.java | 0 .../HmmRuntimeSettingsInitializerTest.java | 0 .../service/JdbcHmmAiAgentToolRunnerTest.java | 0 .../MaskingPolicySynchronizerTest.java | 0 .../service/McpSseServiceTest.java | 0 .../service/OrdsMetadataServiceTest.java | 0 .../service/OrdsProbeServiceTest.java | 0 .../service/PermissionServiceTest.java | 0 .../service/ProbeErrorClassifierTest.java | 0 .../service/SchemaMetadataServiceTest.java | 0 .../service/SecuritySqlScriptServiceTest.java | 0 .../service/SelectAiServiceTest.java | 0 .../service/StructuredDataServiceTest.java | 0 .../service/TokenGeneratorTest.java | 0 .../service/TokenHasherTest.java | 0 .../service/VectorChunkerTest.java | 0 .../service/VectorKnowledgeServiceTest.java | 0 .../service/VpdPolicyServiceTest.java | 0 .../EffectiveMatrixTemplateRenderTest.java | 0 .../web/GuidedFlowTemplateTest.java | 12 +- .../web/MaskingRuleTemplateRenderTest.java | 0 .../web/PermissionTemplateRenderTest.java | 0 .../web/ProbeResultTemplateRenderTest.java | 0 .../web/SettingsTemplateRenderTest.java | 0 .../org.mockito.plugins.MockMaker | 0 445 files changed, 1523 insertions(+), 1885 deletions(-) create mode 100644 ai-web-agent-console/.env.example create mode 100644 ai-web-agent-console/README.md rename {poc4_active_source_20260714/src/agent_console => ai-web-agent-console/ai_web_agent_console}/__init__.py (100%) rename {poc4_active_source_20260714/src/agent_console => ai-web-agent-console/ai_web_agent_console}/audit.py (100%) rename {poc4_active_source_20260714/src/agent_console => ai-web-agent-console/ai_web_agent_console}/auth_gateway.py (95%) rename {poc4_active_source_20260714/src => ai-web-agent-console/ai_web_agent_console}/mcp_result.py (100%) rename {poc4_active_source_20260714/src => ai-web-agent-console/ai_web_agent_console}/mcp_tool_router.py (98%) rename {poc4_active_source_20260714/src/poc3 => ai-web-agent-console/ai_web_agent_console}/model_registry.py (83%) rename {poc4_active_source_20260714/src => ai-web-agent-console/ai_web_agent_console}/oci_genai_sdk.py (100%) rename {poc4_active_source_20260714/src/agent_console => ai-web-agent-console/ai_web_agent_console}/presentation.py (100%) rename {poc4_active_source_20260714/src/agent_console => ai-web-agent-console/ai_web_agent_console}/profile.py (100%) create mode 100644 ai-web-agent-console/ai_web_agent_console/query_contracts.py rename {poc4_active_source_20260714/src/poc3 => ai-web-agent-console/ai_web_agent_console}/questions.py (97%) rename {poc4_active_source_20260714/src/poc4 => ai-web-agent-console/ai_web_agent_console}/scenarios.py (100%) rename poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py => ai-web-agent-console/app.py (87%) rename {poc4_active_source_20260714 => ai-web-agent-console}/config/app_profile.json (100%) rename {poc4_active_source_20260714 => ai-web-agent-console}/config/hmm_demo_scenarios.json (100%) create mode 100644 ai-web-agent-console/config/hmm_hr_query_contracts.json rename {poc4_active_source_20260714 => ai-web-agent-console}/config/mcp_servers.json (89%) rename poc4_active_source_20260714/config/poc3_model_profiles.json => ai-web-agent-console/config/model_profiles.json (94%) rename {poc4_active_source_20260714 => ai-web-agent-console}/config/vpd_token_presets.json (100%) rename {poc4_active_source_20260714 => ai-web-agent-console}/requirements-langgraph.txt (100%) rename {poc4_active_source_20260714 => ai-web-agent-console}/requirements.txt (100%) rename {poc4_active_source_20260714 => ai-web-agent-console}/tests/test_auth_gateway.py (99%) create mode 100644 ai-web-agent-console/tests/test_query_contracts.py rename {poc4_active_source_20260714 => ai-web-agent-console}/tests/test_scenarios.py (86%) rename {sql => database}/adb/00_cleanup.sql (100%) rename {sql => database}/adb/01_dblinks.sql (100%) rename {sql => database}/adb/02_perm_tables.sql (100%) rename {sql => database}/adb/03_seed.sql (98%) rename {sql => database}/adb/04_secure_ctx.sql (100%) rename {sql => database}/adb/05_views.sql (100%) rename {sql => database}/adb/06_policy.sql (100%) rename {sql => database}/adb/06a_redaction.sql (100%) rename {sql => database}/adb/07_end_users.sql (100%) rename {sql => database}/adb/08_tests_user_my.sql (100%) rename {sql => database}/adb/09_tests_user_pg.sql (100%) rename {sql => database}/adb/10_tests_user_both.sql (100%) rename {sql => database}/adb/11_tests_user_none.sql (100%) rename {sql => database}/adb/12_tests_admin_audit.sql (100%) rename {sql => database}/adb/13_dds_variant.sql (100%) rename {sql => database}/adb/14_tests_dds_user.sql (100%) rename {sql => database}/adb/15_dds_cleanup.sql (100%) rename {sql => database}/adb/16_agent_ords_security_local_cleanup.sql (100%) rename {sql => database}/adb/17_agent_ords_security_local_vpd_setup.sql (100%) rename {sql => database}/adb/18_agent_ords_security_local_vpd_test.sql (100%) rename {sql => database}/adb/19_agent_ords_security_local_dds_setup.sql (100%) rename {sql => database}/adb/20_agent_ords_security_local_dds_test.sql (100%) rename {sql => database}/adb/21_agent_ords_security_ords_enable_schema.sql (100%) rename {sql => database}/adb/22_agent_ords_security_ords_handler_setup.sql (100%) rename {sql => database}/adb/23_agent_ords_security_ords_handler_test.sql (100%) rename {sql => database}/adb/24_agent_ords_security_inventory.sql (100%) rename {sql => database}/adb/25_agent_ords_security_backoffice_support.sql (100%) rename {sql => database}/adb/26_agent_ords_security_dynamic_vpd_filter.sql (100%) rename {sql => database}/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql (99%) rename {sql => database}/adb/28_agent_ords_vector_tag_vpd_setup.sql (100%) rename {sql => database}/adb/29_agent_ords_vector_search_ords.sql (100%) rename {sql => database}/adb/30_agent_ords_vector_tag_vpd_test.sql (100%) rename {sql => database}/adb/31_agent_ords_backoffice_ux_metadata.sql (100%) rename {sql => database}/adb/31_dds_standalone_demo_setup.sql (100%) rename {sql => database}/adb/32_dds_vector_tag_setup.sql (100%) rename {sql => database}/adb/33_agent_ords_sql_trace_grant.sql (100%) rename {sql => database}/adb/34_agent_ords_execution_evidence_grant.sql (100%) rename {sql => database}/adb/34_dds_token_data_grant_common_auth.sql (100%) rename {sql => database}/adb/35_dds_token_data_grant_cleanup.sql (100%) rename {sql => database}/adb/36_dds_sales_knowledge_scenario.sql (100%) rename {sql => database}/adb/37_dds_sales_knowledge_scenario_cleanup.sql (100%) rename {sql => database}/adb/38_agent_ords_vector_all_permission_normalization.sql (100%) rename {sql => database}/adb/39_agent_ords_default_user_display_name.sql (100%) rename {sql => database}/adb/40_dds_protection_evidence.sql (100%) rename {sql => database}/adb/41_dds_row_column_fixture.sql (100%) rename {sql => database}/adb/42_agent_ords_fga_execution_audit.sql (100%) rename {sql => database}/adb/42_dds_fga_execution_evidence.sql (100%) rename {sql => database}/adb/43_agent_ords_probe_id_handler_patch.sql (100%) rename {sql => database}/adb/44_dds_mcp_local_end_user_setup.sql (100%) rename {sql => database}/adb/45_dds_mcp_local_end_user_test.sql (100%) rename {sql => database}/adb/46_kb_stakeholder_token_permission_example.sql (100%) rename {sql => database}/adb/47_kb_stakeholder_permission_or_branches.sql (100%) rename {sql => database}/adb/48_kb_permission_condition_codes.sql (100%) rename {sql => database}/adb/49_permission_static_sql_condition.sql (100%) rename {sql => database}/adb/51_kb_vpd_admin_token.sql (100%) rename {sql => database}/adb/52_cleanup_to_kb_vpd_poc.sql (100%) rename {sql => database}/adb/53_drop_legacy_poc_objects.sql (100%) rename {sql => database}/adb/54_drop_legacy_dds_ords_examples.sql (100%) rename {sql => database}/adb/55_reapply_kb_vpd_after_table_reload.sql (100%) rename {sql => database}/adb/56_kb_select_ai_router_team.sql (100%) rename {sql => database}/adb/57_kb_select_ai_router_team_test.sql (100%) rename {sql => database}/adb/58_kb_select_ai_router_api.sql (100%) rename {sql => database}/adb/59_kb_select_ai_router_ords.sql (100%) rename {sql => database}/adb/60_kb_vpd_admin_full_access_token.sql (100%) rename {sql => database}/adb/62_kb_aso_masking_backoffice_metadata.sql (100%) rename {sql => database}/adb/63_kb_aso_masking_rule_runtime.sql (100%) rename {sql => database}/adb/64_kb_aso_masking_default_column_rules.sql (100%) rename {sql => database}/adb/65_kb_select_ai_vpd_query_api.sql (100%) rename {sql => database}/adb/66_kb_select_ai_vpd_query_ords.sql (100%) rename {sql => database}/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql (100%) rename {sql => database}/adb/70_sg_tool_user.sql (100%) rename {sql => database}/adb/71_sg_identity_administration.sql (100%) rename {sql => database}/adb/72_hmm_leave_team_vpd.sql (100%) rename {sql => database}/adb/73_hmm_mcp_vpd_runtime.sql (100%) rename {sql => database}/adb/73_hmm_rds_pg_00_precheck.sql (100%) rename {sql => database}/adb/74_hmm_rds_pg_01_credential.sql (100%) rename {sql => database}/adb/75_hmm_rds_pg_02_acl.sql (100%) rename {sql => database}/adb/76_hmm_rds_pg_03_dblink.sql (100%) rename {sql => database}/adb/77_hmm_rds_pg_04_remote_query.sql (100%) rename {sql => database}/adb/78_hmm_rds_pg_05_views.sql (100%) rename {sql => database}/adb/79_hmm_rds_pg_06_verify.sql (100%) rename {sql => database}/adb/80_hmm_rds_pg_99_cleanup.sql (100%) rename {sql => database}/adb/81_hmm_carrier_access_groups.sql (100%) rename {sql => database}/adb/82_hmm_federation_relationship_comments.sql (100%) rename {sql => database}/source/mysql_setup.sql (96%) rename {sql => database}/source/postgres_hmm_carrier_performance.sql (98%) rename {sql => database}/source/postgres_hmm_carrier_performance_verify.sql (100%) rename {sql => database}/source/postgres_setup.sql (96%) rename {poc4_active_source_20260714/infra => deploy/ai-web-agent-console}/nginx/hmm-portal-auth.locations.conf (100%) rename poc4_active_source_20260714/deploy/systemd/poc4-streamlit-audit.conf => deploy/ai-web-agent-console/systemd/ai-web-agent-console-audit.conf (100%) rename {poc4_active_source_20260714/infra => deploy/ai-web-agent-console}/systemd/hmm-portal-auth.service (88%) rename deploy/{hmm-backoffice.env.example => vpd-backoffice/backoffice.env.example} (100%) rename deploy/{ => vpd-backoffice}/caddy/Caddyfile.template (100%) rename deploy/{ => vpd-backoffice}/caddy/install-caddy-config.sh (100%) rename deploy/{systemd => vpd-backoffice}/vpd-backoffice-structured-data.conf (100%) create mode 100644 docs/design/742-application-oriented-repository-layout/README.md rename poc4_active_source_20260714/HMM_BRAND_REFRESH.md => docs/notes/hmm-brand-refresh.md (92%) delete mode 100644 poc4_active_source_20260714/.env.sample delete mode 100644 poc4_active_source_20260714/SOURCE_README.md delete mode 100644 poc4_active_source_20260714/poc4_active_source_20260714.tar.gz delete mode 100755 poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh delete mode 100755 poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh delete mode 100644 poc4_active_source_20260714/src/poc4/__init__.py create mode 100644 tests/README.md create mode 100644 tests/test_repository_layout.py rename pom.xml => vpd-backoffice/pom.xml (95%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java (97%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java (99%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java (96%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java (88%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java (100%) rename {src => vpd-backoffice/src}/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java (100%) rename {src => vpd-backoffice/src}/main/resources/application.yml (96%) rename {src => vpd-backoffice/src}/main/resources/mapper/AuditMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/BearerTokenMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/GroupMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/MaskingRuleMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/OperationStatusMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/OrdsMetadataMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/PermissionMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/ProtectedObjectMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/SettingMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/StakeholderMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/UserMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/mapper/VpdPolicyMapper.xml (100%) rename {src => vpd-backoffice/src}/main/resources/static/css/app.css (100%) rename {src => vpd-backoffice/src}/main/resources/static/js/app.js (100%) rename {src => vpd-backoffice/src}/main/resources/templates/dashboard.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/effective-matrix.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/error.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/layout.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/mcp-chatbot-result.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/mcp-client-demo-result.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/mcp-reasoning-result.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/ords-handler-source.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/probe-result.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/security-sql-script-explanation.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/vector-search-result.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/vpd-function-source.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/vpd-object-filter-detail.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/vpd-policy-detail.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/fragments/vpd-policy-explanation.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/groups.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/login.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/masking-rules.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/mcp-chatbot.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/mcp-client-demo.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/mcp-reasoning.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/mcp-sse.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/objects.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/operation-status.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/ords-handlers.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/permissions.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/probe.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/roles.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/schema-metadata.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/security-sql-scripts.html (92%) rename {src => vpd-backoffice/src}/main/resources/templates/settings-database.html (99%) rename {src => vpd-backoffice/src}/main/resources/templates/settings.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/structured-data.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/tokens.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/user-masking-rules.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/users.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/vector-knowledge.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/vpd-filter-policies.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/vpd-filter-runtime.html (100%) rename {src => vpd-backoffice/src}/main/resources/templates/vpd-policies.html (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplateTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumnTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifierTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixTemplateRenderTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java (96%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/web/PermissionTemplateRenderTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/web/ProbeResultTemplateRenderTest.java (100%) rename {src => vpd-backoffice/src}/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java (100%) rename {src => vpd-backoffice/src}/test/resources/mockito-extensions/org.mockito.plugins.MockMaker (100%) diff --git a/.env.example b/.env.example index f36b100..a97b948 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,8 @@ export BACKOFFICE_SELECT_AI_PROFILE="" export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS" export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD="" +# Optional deployment-specific JSON contract. Keep project rules out of Java. +export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE="" # --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 --- # 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다. @@ -103,7 +105,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID="" export VPDTEST1_API_KEY="" export VPDTEST2_API_KEY="" -# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) --- +# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) --- # ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합. # 4명의 데모 유저: # vpduser_my → MySQL view 만 SELECT 가능 @@ -116,7 +118,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26" export VPDUSER_NONE_PASSWORD="RowFilter#None26" # --- (3b) OPTIONAL — DDS 변형 데모용 엔드유저 비밀번호 --- -# sql/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용. +# database/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용. # 동일한 4-user 매트릭스를 declarative Data Grants 로 재현. VPD 데모와 공존. export DDSUSER_MY_PASSWORD="DdsGrant#My2026" export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026" @@ -137,7 +139,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083" export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용 # --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) --- -# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성. +# database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성. # ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴. export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com export PG_PORT="5432" diff --git a/.gitignore b/.gitignore index 6631670..357e835 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ logs/ # Java / Maven target/ +# Python +__pycache__/ +*.py[cod] +.venv/ +data/ + # Locally downloaded development tools (for example SQLcl) .tools/ diff --git a/README.md b/README.md index 9d9b1af..955555a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ End-to-End 데모입니다. 행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만 `email` 컬럼은 항상 `NULL` 로 반환. -* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`). +* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`). * DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수. ### 3. 두 가지 구현 — VPD 와 DDS @@ -59,20 +59,21 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드, ## 소스 위치와 Git 저장 기준 -현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다. -두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다. +현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은 +최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다. | 구분 | 저장 위치 | 역할 | |---|---|---| -| VPD/ASO/FGA 관리 백오피스 | `src/main/java/com/cloudhandson/vpdbackoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 | -| VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 | -| VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 | -| VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 | -| 원격 원장 seed/검증 SQL | `sql/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 | +| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 | +| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 | +| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 | +| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 | +| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 | | DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 | -| Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot | -| Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 | -| Streamlit 외피 MCP 설정 | `poc4_active_source_20260714/config/mcp_servers.json` | `kb_mcp`, `kb_vector_mcp` endpoint와 allowlist | +| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 | +| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 | +| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 | +| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON | Git 기준은 다음과 같다. @@ -81,14 +82,12 @@ Git 기준은 다음과 같다. | 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` | | 운영 기준 remote | `gitea` | | 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` | -| 기준 branch | `main` | -| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` | +| HMM 기준 branch | `hmm-backoffice` | 주의: -- 현재 운영·현행화 기준은 `gitea/main`이다. -- `origin/main`은 보조 remote이며, 이 작업공간의 `main`이 `origin/main`보다 앞서 있을 수 있다. -- `poc4_active_source_20260714/`는 배포 VM의 PoC4 활성 소스를 가져온 snapshot이다. 원본은 배포 VM의 `/home/opc/poc_4/` 계열 경로에서 운영되었고, 저장소에는 비밀정보 없이 소스와 설정 sample만 보관한다. +- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다. +- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다. - 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다. --- @@ -111,7 +110,7 @@ Git 기준은 다음과 같다. ## 빠른 시작 (One-Click) ```bash -git clone https://github.com//vpd-permission-poc.git +git clone --branch hmm-backoffice https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git cd vpd-permission-poc # 1) 환경값 채우기 @@ -169,7 +168,7 @@ $EDITOR .env ## 데모 시나리오 — 2×2 source access matrix -`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스): +`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스): | DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 | |---|---|---|---|---| @@ -187,7 +186,7 @@ $EDITOR .env (`1=0` predicate) — **deny by default**. * 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음. -`sql/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트 +`database/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트 스푸핑, DBMS_RLS 변경, 매핑 테이블 SELECT) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을 보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다. @@ -199,7 +198,7 @@ $EDITOR .env ## DDS 설정 핵심 -DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이 +DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이 순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다. ### 사전 조건 @@ -259,8 +258,19 @@ CREATE DATA GRANT admin.alice_apac_grant . ├── run.sh # 원클릭 엔트리포인트 ├── .env.example +├── vpd-backoffice/ # Spring Boot VPD 관리 백오피스 +│ ├── pom.xml +│ └── src/ +├── ai-web-agent-console/ # Streamlit MCP 업무 에이전트 +│ ├── app.py +│ ├── ai_web_agent_console/ +│ ├── config/ +│ └── tests/ +├── dds-backoffice/ # DDS 전용 Spring Boot 백오피스 +├── deploy/ # 애플리케이션별 배포 설정 +├── tests/ # 저장소 폴더 경계와 경로 규칙 검증 ├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼 -├── sql/ +├── database/ │ ├── source/ │ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows │ │ ├── postgres_hmm_carrier_performance.sql # 원격 PG: HMM 가상 선사 8개 + 월간 KPI 144 rows diff --git a/ai-web-agent-console/.env.example b/ai-web-agent-console/.env.example new file mode 100644 index 0000000..e3d01e8 --- /dev/null +++ b/ai-web-agent-console/.env.example @@ -0,0 +1,48 @@ +# AI Web Agent Console local/VM configuration example. +# Copy to .env and keep real credentials out of Git. + +# MCP +HMM_MCP_BEARER_TOKEN= +AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45 + +# OCI Generative AI SDK +OCI_AUTH_TYPE=config_file +OCI_CONFIG_FILE=~/.oci/config +OCI_PROFILE=DEFAULT +OCI_GENAI_COMPARTMENT_ID= +AI_WEB_AGENT_CONSOLE_MODEL_PROFILE=gpt54_mini_oci +AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE=gpt54_mini_oci + +# Optional model route overrides +# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini +# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION=us-chicago-1 +# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com + +# Login gateway. Generate PBKDF2 hash and random cookie secret outside Git. +AI_WEB_AGENT_CONSOLE_LOGIN_USER=demo-admin +AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2= +AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET= +PORTAL_AUTH_BIND_ADDRESS=127.0.0.1 +PORTAL_AUTH_PORT=8621 +PORTAL_AUTH_SESSION_SECONDS=43200 +PORTAL_AUTH_REMEMBER_SECONDS=604800 + +# Product display +AGENT_CONSOLE_NAME=HMM AI 업무 에이전트 +AGENT_CONSOLE_SHORT_NAME=HMM +AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트 +AGENT_CONSOLE_PRIMARY_COLOR=#004b87 + +# Local conversation store +AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH=data/ai_web_agent_console_chat.sqlite3 + +# Optional audit DB. Password and Wallet remain outside Git. +AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE=.env +AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER=ADMIN +AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD= +AI_WEB_AGENT_CONSOLE_AUDIT_DSN= +AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR= +AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD= + +# Optional query contract override +AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH=config/hmm_hr_query_contracts.json diff --git a/ai-web-agent-console/README.md b/ai-web-agent-console/README.md new file mode 100644 index 0000000..799316d --- /dev/null +++ b/ai-web-agent-console/README.md @@ -0,0 +1,57 @@ +# AI Web Agent Console + +HMM 업무 데이터를 MCP 도구로 조회하고 OCI Generative AI로 답변을 구성하는 Streamlit +애플리케이션이다. 과거 PoC 번호나 소스 추출 날짜는 애플리케이션 이름과 패키지 경계로 +사용하지 않는다. + +## 구조 + +```text +ai-web-agent-console/ +├── app.py Streamlit 실행 진입점 +├── ai_web_agent_console/ 인증, 화면, MCP, 모델, 질의 계약 모듈 +├── config/ 제품·모델·MCP·시나리오 JSON +├── tests/ Python 회귀 테스트 +├── .env.example 환경변수 예시 +└── requirements.txt +``` + +## 실행 + +```bash +cd ai-web-agent-console +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/streamlit run app.py --server.address 0.0.0.0 --server.port 8622 +``` + +인증 게이트웨이는 같은 디렉토리에서 다음과 같이 실행한다. + +```bash +.venv/bin/python -m ai_web_agent_console.auth_gateway +``` + +## 설정 + +- `config/app_profile.json`: 제품명, 설명, 아이콘, 색상 +- `config/mcp_servers.json`: MCP endpoint와 허용 도구 +- `config/model_profiles.json`: OCI Generative AI 모델 profile +- `config/vpd_token_presets.json`: 데모 사용자와 토큰 환경변수 매핑 +- `config/hmm_demo_scenarios.json`: 화면의 예제 질문 +- `config/hmm_hr_query_contracts.json`: 질의별 필수 근거와 계산·시간 규칙 + +실제 토큰, DB 비밀번호, Wallet, OCI private key는 Git에 넣지 않는다. 데모 사용자 JSON은 +`HMM_MCP_BEARER_TOKEN` 같은 환경변수 이름만 참조한다. + +## 검증 + +```bash +python3 -m compileall -q app.py ai_web_agent_console tests +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +## 운영 호환성 + +운영 서버의 물리 경로 `/opt/hmm-poc4`와 기존 SQLite 테이블명은 대화 이력과 서비스 전환 +호환을 위해 유지할 수 있다. Git 폴더, Python 패키지와 실행 명령은 이 문서의 새 이름만 +사용한다. diff --git a/poc4_active_source_20260714/src/agent_console/__init__.py b/ai-web-agent-console/ai_web_agent_console/__init__.py similarity index 100% rename from poc4_active_source_20260714/src/agent_console/__init__.py rename to ai-web-agent-console/ai_web_agent_console/__init__.py diff --git a/poc4_active_source_20260714/src/agent_console/audit.py b/ai-web-agent-console/ai_web_agent_console/audit.py similarity index 100% rename from poc4_active_source_20260714/src/agent_console/audit.py rename to ai-web-agent-console/ai_web_agent_console/audit.py diff --git a/poc4_active_source_20260714/src/agent_console/auth_gateway.py b/ai-web-agent-console/ai_web_agent_console/auth_gateway.py similarity index 95% rename from poc4_active_source_20260714/src/agent_console/auth_gateway.py rename to ai-web-agent-console/ai_web_agent_console/auth_gateway.py index f9be767..03fd632 100644 --- a/poc4_active_source_20260714/src/agent_console/auth_gateway.py +++ b/ai-web-agent-console/ai_web_agent_console/auth_gateway.py @@ -28,6 +28,14 @@ from typing import Deque from urllib.parse import parse_qs, urlsplit +def _environment_value(*names: str, default: str = "") -> str: + for name in names: + value = os.environ.get(name) + if value is not None and value.strip(): + return value.strip() + return default + + LOG = logging.getLogger("hmm_portal_auth") SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION" CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF" @@ -54,13 +62,17 @@ class AuthConfig: @classmethod def from_environment(cls) -> "AuthConfig": config = cls( - username=os.environ.get("POC4_LOGIN_USER", "").strip(), - password_pbkdf2=os.environ.get( - "POC4_LOGIN_PASSWORD_PBKDF2", "" - ).strip(), - cookie_secret=os.environ.get( - "POC4_LOGIN_COOKIE_SECRET", "" - ).strip(), + username=_environment_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER" + ), + password_pbkdf2=_environment_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2", + "POC4_LOGIN_PASSWORD_PBKDF2", + ), + cookie_secret=_environment_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET", + "POC4_LOGIN_COOKIE_SECRET", + ), bind_address=os.environ.get( "PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1" ).strip(), @@ -96,9 +108,11 @@ class AuthConfig: def validate(self) -> None: if not self.username or not self.password_pbkdf2: - raise ValueError("POC4 portal login credentials are not configured") + raise ValueError("AI web agent console login credentials are not configured") if len(self.cookie_secret.encode("utf-8")) < 32: - raise ValueError("POC4_LOGIN_COOKIE_SECRET must be at least 32 bytes") + raise ValueError( + "AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes" + ) if self.bind_address not in {"127.0.0.1", "::1"}: raise ValueError("Portal authentication service must bind to loopback") if not 1 <= self.port <= 65535: diff --git a/poc4_active_source_20260714/src/mcp_result.py b/ai-web-agent-console/ai_web_agent_console/mcp_result.py similarity index 100% rename from poc4_active_source_20260714/src/mcp_result.py rename to ai-web-agent-console/ai_web_agent_console/mcp_result.py diff --git a/poc4_active_source_20260714/src/mcp_tool_router.py b/ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py similarity index 98% rename from poc4_active_source_20260714/src/mcp_tool_router.py rename to ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py index 3dfae60..be0edae 100644 --- a/poc4_active_source_20260714/src/mcp_tool_router.py +++ b/ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py @@ -10,11 +10,11 @@ from dataclasses import dataclass import json from typing import Any, Mapping -from src.oci_genai_sdk import ( +from ai_web_agent_console.oci_genai_sdk import ( build_oci_genai_completion_client, temperature_for_model_profile, ) -from src.poc3.model_registry import resolve_model_profile +from ai_web_agent_console.model_registry import resolve_model_profile @dataclass(frozen=True) diff --git a/poc4_active_source_20260714/src/poc3/model_registry.py b/ai-web-agent-console/ai_web_agent_console/model_registry.py similarity index 83% rename from poc4_active_source_20260714/src/poc3/model_registry.py rename to ai-web-agent-console/ai_web_agent_console/model_registry.py index ee9adf6..16ed237 100644 --- a/poc4_active_source_20260714/src/poc3/model_registry.py +++ b/ai-web-agent-console/ai_web_agent_console/model_registry.py @@ -1,7 +1,7 @@ -"""8512/8513 전용 PoC_3 model profile registry. +"""8512/8513 전용 AI Web Agent Console model profile registry. 이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며 -``POC3_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재 +``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재 MCP 실행 경로는 계속 ``mock``일 수 있다. """ @@ -15,11 +15,11 @@ import re from typing import Any, Mapping, Optional -ROOT = Path(__file__).resolve().parents[2] -REGISTRY_PATH = ROOT / "config" / "poc3_model_profiles.json" +ROOT = Path(__file__).resolve().parents[1] +REGISTRY_PATH = ROOT / "config" / "model_profiles.json" DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci" -MODEL_PROFILE_ENV = "POC3_MODEL_PROFILE" -MODEL_PROFILE_DEFAULT_ENV = "POC3_MODEL_PROFILE_DEFAULT" +MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE" +MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT" EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b") MODEL_PROFILE_ALIASES = { "gpt54_mini": "gpt54_mini_oci", @@ -84,36 +84,36 @@ _REQUIRED_PROFILE_FIELDS = ( "poc2_select_ai_profile", "poc2_native_agent_team", "verification_status", - "default_for_poc3", + "default_for_console", "source_tag", "notes", ) _PROFILE_ROUTE_ENV_KEYS = { "gpt55_oci": ( - "POC3_LLM_GPT55_OCI_MODEL_ID", - "POC3_LLM_GPT55_OCI_REGION", - "POC3_LLM_GPT55_OCI_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT", ), "gpt54_mini_oci": ( - "POC3_LLM_GPT54_MINI_OCI_MODEL_ID", - "POC3_LLM_GPT54_MINI_OCI_REGION", - "POC3_LLM_GPT54_MINI_OCI_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT", ), "grok43": ( - "POC3_LLM_GROK43_MODEL_ID", - "POC3_LLM_GROK43_REGION", - "POC3_LLM_GROK43_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT", ), "llama4_maverick": ( - "POC3_LLM_LLAMA4_MAVERICK_MODEL_ID", - "POC3_LLM_LLAMA4_MAVERICK_REGION", - "POC3_LLM_LLAMA4_MAVERICK_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT", ), "llama33_70b": ( - "POC3_LLM_LLAMA33_70B_MODEL_ID", - "POC3_LLM_LLAMA33_70B_REGION", - "POC3_LLM_LLAMA33_70B_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT", ), } @@ -154,7 +154,7 @@ class ModelProfile: poc2_select_ai_profile: str poc2_native_agent_team: str verification_status: str - default_for_poc3: bool + default_for_console: bool source_tag: str notes: str display_order: int = 999 @@ -163,9 +163,9 @@ class ModelProfile: def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile": missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value] if missing: - raise ValueError("PoC_3 model profile fields are missing") - if not isinstance(value.get("default_for_poc3"), bool): - raise ValueError("default_for_poc3 must be boolean") + raise ValueError("AI Web Agent Console model profile fields are missing") + if not isinstance(value.get("default_for_console"), bool): + raise ValueError("default_for_console must be boolean") order = value.get("display_order", 999) if isinstance(order, bool) or not isinstance(order, int) or order < 0: raise ValueError("model profile display_order is invalid") @@ -191,7 +191,7 @@ class ModelProfile: .strip() .upper(), verification_status=str(value["verification_status"]).strip().upper(), - default_for_poc3=value["default_for_poc3"], + default_for_console=value["default_for_console"], source_tag=str(value["source_tag"]).strip(), notes=str(value["notes"]).strip(), display_order=order, @@ -276,7 +276,7 @@ class ModelProfile: "poc2_select_ai_profile": self.poc2_select_ai_profile, "poc2_native_agent_team": self.poc2_native_agent_team, "verification_status": self.verification_status, - "default_for_poc3": self.default_for_poc3, + "default_for_console": self.default_for_console, "source_tag": self.source_tag, "notes": self.notes, } @@ -284,7 +284,7 @@ class ModelProfile: @dataclass(frozen=True) class ModelProfileRegistry: - """검증된 PoC_3 model profile 집합.""" + """검증된 AI Web Agent Console model profile 집합.""" profiles: tuple[ModelProfile, ...] default_model_profile: str @@ -338,32 +338,32 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry: try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - raise ValueError("PoC_3 model profile registry cannot be loaded") from exc + raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc if not isinstance(payload, Mapping): - raise ValueError("PoC_3 model profile registry must be an object") + raise ValueError("AI Web Agent Console model profile registry must be an object") raw_profiles = payload.get("profiles") if not isinstance(raw_profiles, list) or not raw_profiles: - raise ValueError("PoC_3 model profile registry has no profiles") + raise ValueError("AI Web Agent Console model profile registry has no profiles") profiles = tuple( ModelProfile.from_mapping(item) for item in raw_profiles if isinstance(item, Mapping) ) if len(profiles) != len(raw_profiles): - raise ValueError("PoC_3 model profile registry contains an invalid profile") + raise ValueError("AI Web Agent Console model profile registry contains an invalid profile") keys = tuple(item.model_key for item in profiles) if len(set(keys)) != len(keys): - raise ValueError("PoC_3 model profile keys must be unique") + raise ValueError("AI Web Agent Console model profile keys must be unique") if len({item.display_name for item in profiles}) != len(profiles): - raise ValueError("PoC_3 model profile display names must be unique") - defaults = tuple(item.model_key for item in profiles if item.default_for_poc3) + raise ValueError("AI Web Agent Console model profile display names must be unique") + defaults = tuple(item.model_key for item in profiles if item.default_for_console) configured_default = str(payload.get("default_model_profile") or "").strip().lower() if defaults != (configured_default,): - raise ValueError("PoC_3 model profile default is inconsistent") + raise ValueError("AI Web Agent Console model profile default is inconsistent") if configured_default != DEFAULT_MODEL_PROFILE_KEY: - raise ValueError("PoC_3 GPT-5.5 default contract is not satisfied") + raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied") if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys): - raise ValueError("existing PoC_3 selector models are missing") + raise ValueError("existing AI Web Agent Console selector models are missing") actual_answer_routes = { item.model_key: ( item.model_id, @@ -374,16 +374,16 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry: for item in profiles } if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES: - raise ValueError("PoC_3 answer model route mapping is inconsistent") + raise ValueError("AI Web Agent Console answer model route mapping is inconsistent") if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT: raise ValueError("PoC_2 source commit is inconsistent") if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles): raise ValueError("PoC_2 source tag is inconsistent") if payload.get("schema_version") != 1: - raise ValueError("unsupported PoC_3 model profile registry schema") + raise ValueError("unsupported AI Web Agent Console model profile registry schema") registry_name = str(payload.get("registry_name") or "").strip() if not registry_name: - raise ValueError("PoC_3 model profile registry name is missing") + raise ValueError("AI Web Agent Console model profile registry name is missing") return ModelProfileRegistry( profiles=profiles, default_model_profile=configured_default, diff --git a/poc4_active_source_20260714/src/oci_genai_sdk.py b/ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py similarity index 100% rename from poc4_active_source_20260714/src/oci_genai_sdk.py rename to ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/ai-web-agent-console/ai_web_agent_console/presentation.py similarity index 100% rename from poc4_active_source_20260714/src/agent_console/presentation.py rename to ai-web-agent-console/ai_web_agent_console/presentation.py diff --git a/poc4_active_source_20260714/src/agent_console/profile.py b/ai-web-agent-console/ai_web_agent_console/profile.py similarity index 100% rename from poc4_active_source_20260714/src/agent_console/profile.py rename to ai-web-agent-console/ai_web_agent_console/profile.py diff --git a/ai-web-agent-console/ai_web_agent_console/query_contracts.py b/ai-web-agent-console/ai_web_agent_console/query_contracts.py new file mode 100644 index 0000000..8427a73 --- /dev/null +++ b/ai-web-agent-console/ai_web_agent_console/query_contracts.py @@ -0,0 +1,407 @@ +"""Configuration-driven query and answer evidence contracts.""" + +from __future__ import annotations + +import ast +from decimal import Decimal, InvalidOperation +from functools import lru_cache +import json +import os +from pathlib import Path +import re +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json" + + +def _contract_file() -> Path: + configured = str( + os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH") + or os.getenv("POC4_QUERY_CONTRACTS_PATH") + or "" + ).strip() + if not configured: + return DEFAULT_CONTRACT_FILE + path = Path(configured).expanduser() + return path if path.is_absolute() else ROOT / path + + +@lru_cache(maxsize=4) +def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]: + path = Path(path_text) + if not path.exists(): + return () + value = json.loads(path.read_text(encoding="utf-8")) + contracts = value.get("contracts") if isinstance(value, Mapping) else None + if not isinstance(contracts, list): + raise ValueError("query contracts must contain a contracts array") + return tuple(item for item in contracts if isinstance(item, Mapping)) + + +def load_query_contracts() -> tuple[Mapping[str, Any], ...]: + return _load_contract_file(str(_contract_file())) + + +def matching_query_contracts( + question: str, + tool_name: str, +) -> tuple[Mapping[str, Any], ...]: + normalized_question = " ".join(str(question or "").casefold().split()) + normalized_tool = str(tool_name or "").strip() + selected: list[Mapping[str, Any]] = [] + for contract in load_query_contracts(): + tools = contract.get("applies_to_tools") + if isinstance(tools, list) and normalized_tool not in { + str(item) for item in tools + }: + continue + intent = contract.get("intent_match") + if not isinstance(intent, Mapping): + continue + subject_terms = [ + str(item).casefold() + for item in intent.get("subject_terms_any", []) + if str(item).strip() + ] + action_terms = [ + str(item).casefold() + for item in intent.get("action_terms_any", []) + if str(item).strip() + ] + if subject_terms and not any( + term in normalized_question for term in subject_terms + ): + continue + if action_terms and not any( + term in normalized_question for term in action_terms + ): + continue + selected.append(contract) + return tuple(selected) + + +def query_contract_guidance(question: str, tool_name: str) -> str: + contracts = matching_query_contracts(question, tool_name) + if not contracts: + return "" + return json.dumps( + {"query_contracts": contracts}, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def append_query_contract_guidance( + tool_query: str, + *, + original_question: str, + tool_name: str, +) -> str: + guidance = query_contract_guidance(original_question, tool_name) + normalized = str(tool_query or "").strip() + if not guidance: + return normalized + return ( + f"{normalized}\n" + "다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. " + f"계약: {guidance}" + ) + + +def _json_data_result(value: str) -> Any: + marker = "DATA_RESULT" + position = value.find(marker) + if position < 0: + return None + remainder = value[position + len(marker) :] + object_position = remainder.find("{") + array_position = remainder.find("[") + positions = [ + candidate + for candidate in (object_position, array_position) + if candidate >= 0 + ] + if not positions: + return None + candidate = remainder[min(positions) :] + try: + parsed, _ = json.JSONDecoder().raw_decode(candidate) + except (TypeError, ValueError, json.JSONDecodeError): + return None + return parsed + + +def _evidence_rows(value: Any) -> list[Mapping[str, Any]]: + rows: list[Mapping[str, Any]] = [] + if isinstance(value, Mapping): + rows.append(value) + for item in value.values(): + rows.extend(_evidence_rows(item)) + elif isinstance(value, list): + for item in value: + rows.extend(_evidence_rows(item)) + elif isinstance(value, str): + parsed = _json_data_result(value) + if parsed is not None: + rows.extend(_evidence_rows(parsed)) + return rows + + +def _decimal(value: Any) -> Decimal: + if isinstance(value, bool) or value is None: + raise InvalidOperation + return Decimal(str(value)) + + +def _evaluate_contract_expression( + expression: str, + row: Mapping[str, Any], +) -> Decimal: + tree = ast.parse(expression, mode="eval") + + def evaluate(node: ast.AST) -> Decimal: + if isinstance(node, ast.Expression): + return evaluate(node.body) + if isinstance(node, ast.Name): + key = node.id.casefold() + if key not in row: + raise InvalidOperation + return _decimal(row[key]) + if isinstance(node, ast.Constant): + return _decimal(node.value) + if isinstance(node, ast.UnaryOp) and isinstance( + node.op, (ast.UAdd, ast.USub) + ): + value = evaluate(node.operand) + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div) + ): + left = evaluate(node.left) + right = evaluate(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + return left / right + raise ValueError("unsupported contract expression") + + return evaluate(tree) + + +def evidence_contract_report( + contracts: tuple[Mapping[str, Any], ...], + evidence: Any, +) -> list[dict[str, Any]]: + rows = [ + {str(key).strip().casefold(): value for key, value in row.items()} + for row in _evidence_rows(evidence) + ] + reports: list[dict[str, Any]] = [] + for contract in contracts: + data_contract = contract.get("data_contract") + if not isinstance(data_contract, Mapping): + continue + required = [ + str(item).strip().casefold() + for item in data_contract.get("required_fields", []) + if str(item).strip() + ] + matching_row = next( + (row for row in rows if required and all(key in row for key in required)), + None, + ) + observed = sorted( + { + key + for row in rows + for key in row + if not required or key in required + } + ) + missing = [ + key.upper() + for key in required + if matching_row is None or key not in matching_row + ] + computed_checks: list[dict[str, Any]] = [] + computed_fields = data_contract.get("computed_fields") + if matching_row is not None and isinstance(computed_fields, Mapping): + for field, definition in computed_fields.items(): + normalized_field = str(field).strip().casefold() + expression = ( + str(definition.get("expression") or "").strip() + if isinstance(definition, Mapping) + else "" + ) + if not normalized_field or not expression: + continue + try: + actual = _decimal(matching_row.get(normalized_field)) + expected = _evaluate_contract_expression( + expression, + matching_row, + ) + matches = actual == expected + computed_checks.append( + { + "field": normalized_field.upper(), + "expression": expression, + "actual": str(actual), + "expected": str(expected), + "satisfied": matches, + } + ) + except (InvalidOperation, ValueError, ZeroDivisionError): + computed_checks.append( + { + "field": normalized_field.upper(), + "expression": expression, + "satisfied": False, + } + ) + calculations_satisfied = all( + bool(check.get("satisfied")) for check in computed_checks + ) + temporal_checks: list[dict[str, Any]] = [] + temporal_contract = contract.get("temporal_contract") + if matching_row is not None and isinstance(temporal_contract, Mapping): + status_fields = [ + str(item).strip().casefold() + for item in temporal_contract.get("period_status_fields", []) + if str(item).strip() + ] + past_status = str( + temporal_contract.get("past_period_status") or "" + ).strip() + observed_status = next( + ( + str(matching_row.get(field) or "").strip() + for field in status_fields + if str(matching_row.get(field) or "").strip() + ), + "", + ) + if observed_status == past_status and past_status: + decision_field = str( + temporal_contract.get("past_period_decision_field") or "" + ).strip().casefold() + decision_value = str( + temporal_contract.get("past_period_decision_value") or "" + ).strip() + actual_decision = str( + matching_row.get(decision_field) or "" + ).strip() + temporal_checks.append( + { + "check": "past_period_decision", + "field": decision_field.upper(), + "expected": decision_value, + "actual": actual_decision, + "satisfied": bool( + decision_field + and decision_value + and actual_decision == decision_value + ), + } + ) + forbidden_patterns = [ + str(item).strip() + for item in temporal_contract.get( + "forbidden_past_period_decision_field_patterns", [] + ) + if str(item).strip() + ] + forbidden_fields = sorted( + key.upper() + for key in matching_row + if any( + re.search(pattern, key, flags=re.IGNORECASE) + for pattern in forbidden_patterns + ) + ) + temporal_checks.append( + { + "check": "no_past_period_yes_no_decision", + "forbidden_fields": forbidden_fields, + "satisfied": not forbidden_fields, + } + ) + required_counts = [ + str(item).strip().casefold() + for item in temporal_contract.get( + "validate_stated_days_against", [] + ) + if str(item).strip() + ] + temporal_checks.append( + { + "check": "date_range_counts", + "required_fields": [ + field.upper() for field in required_counts + ], + "missing_fields": [ + field.upper() + for field in required_counts + if field not in matching_row + ], + "satisfied": all( + field in matching_row for field in required_counts + ), + } + ) + temporal_satisfied = all( + bool(check.get("satisfied")) for check in temporal_checks + ) + reports.append( + { + "contract_id": str(contract.get("id") or ""), + "satisfied": bool( + matching_row is not None + and not missing + and calculations_satisfied + and temporal_satisfied + ), + "required_fields": [key.upper() for key in required], + "observed_required_fields": [key.upper() for key in observed], + "missing_fields": missing, + "computed_field_checks": computed_checks, + "temporal_contract_checks": temporal_checks, + "validated_record": ( + { + key.upper(): matching_row.get(key) + for key in required + } + if matching_row is not None + else {} + ), + } + ) + return reports + + +def missing_evidence_message( + contracts: tuple[Mapping[str, Any], ...], +) -> str: + for contract in contracts: + answer_contract = contract.get("answer_contract") + if not isinstance(answer_contract, Mapping): + continue + message = str(answer_contract.get("missing_evidence_message") or "").strip() + if message: + return message + return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다." + + +__all__ = [ + "append_query_contract_guidance", + "evidence_contract_report", + "load_query_contracts", + "matching_query_contracts", + "missing_evidence_message", + "query_contract_guidance", +] diff --git a/poc4_active_source_20260714/src/poc3/questions.py b/ai-web-agent-console/ai_web_agent_console/questions.py similarity index 97% rename from poc4_active_source_20260714/src/poc3/questions.py rename to ai-web-agent-console/ai_web_agent_console/questions.py index da01a1e..ba6bd1b 100644 --- a/poc4_active_source_20260714/src/poc3/questions.py +++ b/ai-web-agent-console/ai_web_agent_console/questions.py @@ -1,4 +1,4 @@ -"""PoC_3 preset catalog와 현재 질문 기반의 결정적 intent router.""" +"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router.""" from __future__ import annotations @@ -90,7 +90,7 @@ COMMON_DEMO_QUESTIONS = ( _QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS} if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS): - raise RuntimeError("duplicate PoC_3 demo question id") + raise RuntimeError("duplicate AI Web Agent Console demo question id") def question_by_id(question_id: str) -> DemoQuestion: @@ -100,7 +100,7 @@ def question_by_id(question_id: str) -> DemoQuestion: try: return _QUESTION_BY_ID[normalized] except KeyError: - raise ValueError("unknown PoC_3 demo question id") from None + raise ValueError("unknown AI Web Agent Console demo question id") from None def normalize_scenario_id(value: object) -> str | None: diff --git a/poc4_active_source_20260714/src/poc4/scenarios.py b/ai-web-agent-console/ai_web_agent_console/scenarios.py similarity index 100% rename from poc4_active_source_20260714/src/poc4/scenarios.py rename to ai-web-agent-console/ai_web_agent_console/scenarios.py diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/ai-web-agent-console/app.py similarity index 87% rename from poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py rename to ai-web-agent-console/app.py index c1510ca..26d6e08 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/ai-web-agent-console/app.py @@ -1,7 +1,7 @@ -"""Minimal MCP discovery UI for PoC4. +"""HMM AI Web Agent Console Streamlit entrypoint. Run: - streamlit run apps/poc4/mcp_discovery_ui.py --server.port 8622 + streamlit run app.py --server.port 8622 This screen intentionally does not use the existing LangGraph/actor-context pipeline. It discovers MCP tools, selects a query-capable tool, and calls it @@ -29,7 +29,7 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener -ROOT = Path(__file__).resolve().parents[2] +ROOT = Path(__file__).resolve().parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -37,32 +37,38 @@ import streamlit as st import streamlit.components.v1 as components import oracledb -from src.mcp_tool_router import ( +from ai_web_agent_console.mcp_tool_router import ( McpTool, McpToolRouterError, RoutedMcpTool, build_mcp_tool_arguments, route_mcp_tool_across_servers_with_llm, ) -from src.mcp_result import ( +from ai_web_agent_console.mcp_result import ( has_actionable_text_result, status_result_evidence, status_result_summary, text_result, ) -from src.oci_genai_sdk import ( +from ai_web_agent_console.oci_genai_sdk import ( build_oci_genai_completion_client, temperature_for_model_profile, ) -from src.poc3.model_registry import load_model_registry, resolve_model_profile -from src.agent_console.presentation import ( +from ai_web_agent_console.model_registry import load_model_registry, resolve_model_profile +from ai_web_agent_console.presentation import ( apply_console_theme, render_console_header, render_login_brand, ) -from src.agent_console.audit import render_hmm_audit_tab -from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile -from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios +from ai_web_agent_console.audit import render_hmm_audit_tab +from ai_web_agent_console.profile import AppProfile, AppProfileError, load_app_profile +from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios +from ai_web_agent_console.query_contracts import ( + append_query_contract_guidance, + evidence_contract_report, + matching_query_contracts, + missing_evidence_message, +) LOG = logging.getLogger(__name__) @@ -75,7 +81,10 @@ CHAT_TURNS_PER_PAGE = 3 CHAT_CONTEXT_TURNS = 4 MAX_AGENT_TOOL_STEPS = 4 AGENT_FINAL_ROUTE = "__final__" -COMPLEX_REASONING_MODEL_PROFILE_ENV = "POC4_COMPLEX_REASONING_MODEL_PROFILE" +COMPLEX_REASONING_MODEL_PROFILE_ENV = ( + "AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE" +) +LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV = "POC4_COMPLEX_REASONING_MODEL_PROFILE" DEFAULT_COMPLEX_REASONING_MODEL_PROFILE = "grok43" DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE = "gpt54_mini_oci" DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci" @@ -92,7 +101,9 @@ PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User" PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires" AUDIT_DB_ENV_FILE = Path( - os.environ.get("POC4_AUDIT_DB_ENV_FILE", str(ENV_FILE)) + os.environ.get("AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE") + or os.environ.get("POC4_AUDIT_DB_ENV_FILE") + or str(ENV_FILE) ).expanduser() DEFAULT_AUDIT_DB_DSN = ( "(description=(retry_count=3)(retry_delay=1)" @@ -102,1364 +113,6 @@ DEFAULT_AUDIT_DB_DSN = ( "(security=(ssl_server_dn_match=yes)))" ) _OPAQUE_BEARER = re.compile(r"^[\x21-\x7e]{1,4096}$") -KB_THEME_CSS = """ - -""" - -HMM_MINIMAL_THEME_CSS = """ - -""" - -HMM_CLEAN_THEME_CSS = """ - -""" - @dataclass(frozen=True) class McpServer: @@ -1549,7 +202,10 @@ def _utc_now() -> str: def _chat_db_path() -> Path: - configured = _runtime_env_value("POC4_CHAT_DB_PATH") + configured = _runtime_env_value( + "AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH", + "POC4_CHAT_DB_PATH", + ) if not configured: return CHAT_DB_FILE path = Path(configured).expanduser() @@ -1907,8 +563,12 @@ def _apply_console_theme(profile: AppProfile) -> None: apply_console_theme(st, profile) -def _portal_auth_value(name: str) -> str: - return (os.environ.get(name) or _dotenv_value(name)).strip() +def _portal_auth_value(*names: str) -> str: + for name in names: + value = (os.environ.get(name) or _dotenv_value(name)).strip() + if value: + return value + return "" def _proxy_auth_headers() -> tuple[str, int]: @@ -1925,7 +585,10 @@ def _proxy_auth_headers() -> tuple[str, int]: def _restore_portal_proxy_session() -> None: username, expires_at = _proxy_auth_headers() - expected_username = _portal_auth_value("POC4_LOGIN_USER") + expected_username = _portal_auth_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_USER", + "POC4_LOGIN_USER", + ) authenticated = bool( username and expected_username @@ -2040,25 +703,35 @@ class AuditLogError(RuntimeError): def _audit_db_env_value(name: str, default: str = "") -> str: - return ( - os.environ.get(name) - or _dotenv_value(name, AUDIT_DB_ENV_FILE) - or default - ).strip() + names = [name] + if name.startswith("AI_WEB_AGENT_CONSOLE_"): + names.append(name.replace("AI_WEB_AGENT_CONSOLE_", "POC4_", 1)) + for candidate in names: + value = ( + os.environ.get(candidate) + or _dotenv_value(candidate, AUDIT_DB_ENV_FILE) + ).strip() + if value: + return value + return default.strip() @st.cache_resource(show_spinner=False) def _audit_db_pool() -> Any: - password = _audit_db_env_value("POC4_AUDIT_DB_PASSWORD") + password = _audit_db_env_value("AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD") if not password: raise AuditLogError("감사로그 DB 접속 설정을 확인해 주세요.") - dsn = _audit_db_env_value("POC4_AUDIT_DSN", DEFAULT_AUDIT_DB_DSN) - wallet_password = _audit_db_env_value("POC4_AUDIT_WALLET_PASSWORD") + dsn = _audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_DSN", DEFAULT_AUDIT_DB_DSN + ) + wallet_password = _audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD" + ) pool_options: dict[str, Any] = {} if wallet_password: wallet_dir = Path( _audit_db_env_value( - "POC4_AUDIT_WALLET_DIR", + "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR", "/home/opc/apps/vpd-backoffice/wallet", ) ).expanduser().resolve() @@ -2075,7 +748,9 @@ def _audit_db_pool() -> Any: ) try: return oracledb.create_pool( - user=_audit_db_env_value("POC4_AUDIT_DB_USER", "ADMIN"), + user=_audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER", "ADMIN" + ), password=password, dsn=dsn, min=1, @@ -3456,11 +2131,15 @@ def _enrich_answer_with_business_evidence( return f"{verified}\n\n{normalized}".strip() -def _runtime_env_value(name: object) -> str: - key = str(name or "").strip() - if not key: - return "" - return (os.environ.get(key) or _dotenv_value(key)).strip() +def _runtime_env_value(*names: object) -> str: + for name in names: + key = str(name or "").strip() + if not key: + continue + value = (os.environ.get(key) or _dotenv_value(key)).strip() + if value: + return value + return "" def _looks_like_http_url(value: str) -> bool: @@ -4022,6 +2701,8 @@ def _complex_reasoning_model_profile(default_model_profile: str) -> str: configured = ( os.environ.get(COMPLEX_REASONING_MODEL_PROFILE_ENV) or _dotenv_value(COMPLEX_REASONING_MODEL_PROFILE_ENV) + or os.environ.get(LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV) + or _dotenv_value(LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV) ).strip() return configured or DEFAULT_COMPLEX_REASONING_MODEL_PROFILE or default_model_profile @@ -4866,6 +3547,11 @@ def run_mcp_agent_loop( if route is None: route = next(iter(routes_by_key.values())) route_key = _route_key(route.server_id, route.tool.name) + tool_query = append_query_contract_guidance( + tool_query, + original_question=question, + tool_name=route.tool.name, + ) is_distinct_vector_query = ( _is_vector_route(route) and tool_query not in completed_vector_queries @@ -4890,6 +3576,11 @@ def run_mcp_agent_loop( route, observations, ) + tool_query = append_query_contract_guidance( + tool_query, + original_question=question, + tool_name=route.tool.name, + ) thought = ( f"{thought} / 중복 MCP route {repeated_route_key} 대신 " f"미호출 route {route_key}로 전환합니다." @@ -5340,8 +4031,15 @@ def _prepare_hmm_hr_tool_query( """Prepare an HMM HR query without inheriting retired KB/VPD prompt rules.""" fallback = str(question or "").strip() + def with_contract(value: str) -> str: + return append_query_contract_guidance( + value, + original_question=fallback, + tool_name=tool.name, + ) + if tool.name in {"resolve_hr_term", "search_hr_policy"}: - return fallback + return with_contract(fallback) contextual_question = _hmm_demo_user_context( fallback, user_id=selected_user_id.strip(), @@ -5350,7 +4048,7 @@ def _prepare_hmm_hr_tool_query( scope=selected_user_scope.strip(), ) if tool.name == "search_carrier_performance": - return contextual_question + return with_contract(contextual_question) try: profile = resolve_model_profile(model_profile_key) client = build_oci_genai_completion_client( @@ -5390,13 +4088,15 @@ def _prepare_hmm_hr_tool_query( parsed = json.loads(text) rewritten = _clean_agent_tool_query(parsed.get("tool_query"), fallback) except Exception: - return contextual_question - return _hmm_demo_user_context( - rewritten or fallback, - user_id=selected_user_id.strip(), - role=selected_user_role.strip(), - team=selected_user_team.strip(), - scope=selected_user_scope.strip(), + return with_contract(contextual_question) + return with_contract( + _hmm_demo_user_context( + rewritten or fallback, + user_id=selected_user_id.strip(), + role=selected_user_role.strip(), + team=selected_user_team.strip(), + scope=selected_user_scope.strip(), + ) ) @@ -5413,6 +4113,72 @@ def synthesize_answer( security_evidence: Mapping[str, Any] | None = None, business_evidence: Mapping[str, Any] | None = None, ) -> Mapping[str, Any]: + contract_tools = {tool.name} + contract_tools.update( + str(step.get("tool_name") or "") + for step in (agent_steps or []) + if isinstance(step, Mapping) + ) + active_contracts_by_id: dict[str, Mapping[str, Any]] = {} + for contract_tool in contract_tools: + for contract in matching_query_contracts(question, contract_tool): + contract_id = str(contract.get("id") or "") + active_contracts_by_id[contract_id] = contract + active_contracts = tuple(active_contracts_by_id.values()) + contract_evidence = { + "mcp_result": mcp_result, + "agent_steps": list(agent_steps or []), + } + contract_report = evidence_contract_report(active_contracts, contract_evidence) + + if contract_report and any( + not bool(report.get("satisfied")) for report in contract_report + ): + missing_fields = sorted( + { + str(field) + for report in contract_report + for field in report.get("missing_fields", []) + } + ) + failed_calculations = sorted( + { + str(check.get("field") or "") + for report in contract_report + for check in report.get("computed_field_checks", []) + if not bool(check.get("satisfied")) + } + ) + failed_temporal_checks = sorted( + { + str(check.get("check") or "") + for report in contract_report + for check in report.get("temporal_contract_checks", []) + if not bool(check.get("satisfied")) + } + ) + evidence_issues = [] + if missing_fields: + evidence_issues.append("필수 필드 누락: " + ", ".join(missing_fields)) + if failed_calculations: + evidence_issues.append( + "계산 결과 불일치: " + ", ".join(failed_calculations) + ) + if failed_temporal_checks: + evidence_issues.append( + "시간 기준 계약 불일치: " + ", ".join(failed_temporal_checks) + ) + return { + "answer": missing_evidence_message(active_contracts), + "basis": evidence_issues or ["질의 계약 검증 실패"], + "limitations": ( + "필수 원장 근거가 충족되지 않아 잔여일수나 승인 가능 여부를 " + "추정하지 않았습니다." + ), + "query_contracts": list(active_contracts), + "evidence_contract_report": contract_report, + } + def build_user_prompt(*, compact: bool) -> str: evidence_items = 10 if compact else 20 evidence_text = 260 if compact else 380 @@ -5421,6 +4187,8 @@ def synthesize_answer( prompt_payload = { "question": question, "required_answer_checks": _answer_requirements(question), + "query_contracts": list(active_contracts), + "evidence_contract_report": contract_report, "conversation": [] if compact else conversation[-4:], "selected_route": { "server_id": server.server_id, @@ -5536,6 +4304,10 @@ def synthesize_answer( "FC_ID scoping for customer data, plus active catalog document/chunk evidence. " "It takes precedence when Select AI returns an ambiguous or incorrect zero-row " "result. Never expose customer rows outside the business evidence scope. " + "The query_contracts and evidence_contract_report are authoritative. Follow " + "their required fields, calculations, temporal semantics, missing-record " + "semantics, forbidden fallbacks, and answer restrictions. Keep data facts " + "and policy requirements separate. " "Show audit_log_id when it is provided. Follow every required_answer_checks item in the user " "payload. When the user requests a result list, include every " "row provided in the MCP evidence, up to 20 rows, and state the total " @@ -5600,6 +4372,8 @@ def synthesize_answer( if evidence_verified: enriched["limitations"] = "" enriched["basis"] = basis[:5] + enriched["query_contracts"] = list(active_contracts) + enriched["evidence_contract_report"] = contract_report return enriched except Exception as exc: last_error = { @@ -6442,6 +5216,8 @@ def _process_submitted_question( ) assistant_message: dict[str, Any] synthesis_error_details: dict[str, Any] = {} + query_contract_details: list[Any] = [] + evidence_contract_details: list[Any] = [] answer_model_profile = reasoning_model_profile answer_synthesis_fallback_model = "" try: @@ -6473,6 +5249,10 @@ def _process_submitted_question( "basis": synthesized.get("basis", []), "limitations": str(synthesized.get("limitations") or ""), } + query_contract_details = list(synthesized.get("query_contracts") or []) + evidence_contract_details = list( + synthesized.get("evidence_contract_report") or [] + ) except AnswerSynthesisError as exc: synthesis_error_details = getattr(exc, "diagnostics", {}) or {} fallback_profile = DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE @@ -6512,6 +5292,12 @@ def _process_submitted_question( "basis": synthesized.get("basis", []), "limitations": str(synthesized.get("limitations") or ""), } + query_contract_details = list( + synthesized.get("query_contracts") or [] + ) + evidence_contract_details = list( + synthesized.get("evidence_contract_report") or [] + ) except AnswerSynthesisError as fallback_exc: synthesis_error_details = { "primary": synthesis_error_details, @@ -6586,6 +5372,8 @@ def _process_submitted_question( "agent_stop_reason": agent_stop_reason, "security_evidence": security_evidence, "business_evidence": business_evidence, + "query_contracts": query_contract_details, + "evidence_contract_report": evidence_contract_details, "execution_events": execution_events, "discovered_routes": [ { diff --git a/poc4_active_source_20260714/config/app_profile.json b/ai-web-agent-console/config/app_profile.json similarity index 100% rename from poc4_active_source_20260714/config/app_profile.json rename to ai-web-agent-console/config/app_profile.json diff --git a/poc4_active_source_20260714/config/hmm_demo_scenarios.json b/ai-web-agent-console/config/hmm_demo_scenarios.json similarity index 100% rename from poc4_active_source_20260714/config/hmm_demo_scenarios.json rename to ai-web-agent-console/config/hmm_demo_scenarios.json diff --git a/ai-web-agent-console/config/hmm_hr_query_contracts.json b/ai-web-agent-console/config/hmm_hr_query_contracts.json new file mode 100644 index 0000000..1014a9f --- /dev/null +++ b/ai-web-agent-console/config/hmm_hr_query_contracts.json @@ -0,0 +1,84 @@ +{ + "version": 1, + "contracts": [ + { + "id": "leave_balance_eligibility", + "description": "연차·휴가 잔여와 신청 가능 여부를 원장 연도 및 구성값으로 검증한다.", + "applies_to_tools": [ + "search_hr_data" + ], + "intent_match": { + "subject_terms_any": [ + "연차", + "휴가" + ], + "action_terms_any": [ + "잔여", + "가능", + "승인", + "신청", + "사용할 수" + ] + }, + "data_contract": { + "source_object": "HMM_LEAVE_BALANCES", + "identity_fields": [ + "EMPLOYEE_CODE" + ], + "required_fields": [ + "LEAVE_YEAR", + "ANNUAL_ENTITLEMENT", + "CARRYOVER_DAYS", + "USED_DAYS", + "PENDING_DAYS", + "AVAILABLE_REMAINING_DAYS" + ], + "computed_fields": { + "AVAILABLE_REMAINING_DAYS": { + "expression": "ANNUAL_ENTITLEMENT + CARRYOVER_DAYS - USED_DAYS - PENDING_DAYS", + "authoritative_source": "DATABASE_VIRTUAL_COLUMN" + } + }, + "missing_record_semantics": "NO_BALANCE_DATA_FOR_YEAR", + "forbidden_missing_record_fallbacks": [ + "ZERO", + "NVL", + "COALESCE", + "OUTER_JOIN_DEFAULT", + "LITERAL_VALUE" + ] + }, + "temporal_contract": { + "current_balance_year": "LATEST_AVAILABLE_LEAVE_YEAR", + "requested_period_and_current_balance_are_separate": true, + "period_status_fields": [ + "REQUEST_PERIOD_LABEL", + "PERIOD_STATUS" + ], + "past_period_status": "HISTORICAL_REQUEST_PERIOD", + "past_period_decision": "DO_NOT_DECIDE_NEW_APPLICATION_FROM_CURRENT_BALANCE", + "past_period_decision_field": "APPLICATION_DECISION_STATUS", + "past_period_decision_value": "NOT_APPLICABLE_FOR_HISTORICAL_PERIOD", + "forbidden_past_period_decision_field_patterns": [ + "^CAN_", + "^APPROVAL_(ALLOWED|DENIED)$" + ], + "validate_stated_days_against": [ + "INCLUSIVE_CALENDAR_DAYS", + "MONDAY_TO_FRIDAY_DAYS" + ], + "day_count_mismatch_status": "DATE_COUNT_REQUIRES_VALIDATION" + }, + "answer_contract": { + "separate_data_facts_and_policy_requirements": true, + "deny_conclusions_without_required_fields": [ + "ZERO_REMAINING_DAYS", + "LEAVE_APPROVAL_ALLOWED", + "LEAVE_APPROVAL_DENIED" + ], + "missing_evidence_response": "STATE_INSUFFICIENT_EVIDENCE_WITHOUT_INVENTING_VALUES", + "missing_evidence_message": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다." + } + } + ] +} diff --git a/poc4_active_source_20260714/config/mcp_servers.json b/ai-web-agent-console/config/mcp_servers.json similarity index 89% rename from poc4_active_source_20260714/config/mcp_servers.json rename to ai-web-agent-console/config/mcp_servers.json index 8a6242c..3da95f4 100644 --- a/poc4_active_source_20260714/config/mcp_servers.json +++ b/ai-web-agent-console/config/mcp_servers.json @@ -8,7 +8,7 @@ "transport": "http", "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", "auth_token_env": "HMM_MCP_BEARER_TOKEN", - "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", + "timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS", "default_tool": "search_hr_data", "router_model_profile": "gpt54_mini_oci", "tool_allowlist": [ diff --git a/poc4_active_source_20260714/config/poc3_model_profiles.json b/ai-web-agent-console/config/model_profiles.json similarity index 94% rename from poc4_active_source_20260714/config/poc3_model_profiles.json rename to ai-web-agent-console/config/model_profiles.json index 2883a08..f7c973f 100644 --- a/poc4_active_source_20260714/config/poc3_model_profiles.json +++ b/ai-web-agent-console/config/model_profiles.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "registry_name": "POC3_8512_8513_MODEL_PROFILES", + "registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES", "default_model_profile": "gpt55_oci", "source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f", "profiles": [ @@ -15,7 +15,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2", "verification_status": "VERIFIED_WITH_WARNINGS", - "default_for_poc3": true, + "default_for_console": true, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.", "display_order": 0 @@ -31,7 +31,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1", "verification_status": "PARTIAL_VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.", "display_order": 1 @@ -47,7 +47,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3", "verification_status": "VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", "display_order": 2 @@ -63,7 +63,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3", "verification_status": "VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", "display_order": 3 @@ -79,7 +79,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3", "verification_status": "VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", "display_order": 4 diff --git a/poc4_active_source_20260714/config/vpd_token_presets.json b/ai-web-agent-console/config/vpd_token_presets.json similarity index 100% rename from poc4_active_source_20260714/config/vpd_token_presets.json rename to ai-web-agent-console/config/vpd_token_presets.json diff --git a/poc4_active_source_20260714/requirements-langgraph.txt b/ai-web-agent-console/requirements-langgraph.txt similarity index 100% rename from poc4_active_source_20260714/requirements-langgraph.txt rename to ai-web-agent-console/requirements-langgraph.txt diff --git a/poc4_active_source_20260714/requirements.txt b/ai-web-agent-console/requirements.txt similarity index 100% rename from poc4_active_source_20260714/requirements.txt rename to ai-web-agent-console/requirements.txt diff --git a/poc4_active_source_20260714/tests/test_auth_gateway.py b/ai-web-agent-console/tests/test_auth_gateway.py similarity index 99% rename from poc4_active_source_20260714/tests/test_auth_gateway.py rename to ai-web-agent-console/tests/test_auth_gateway.py index 689150a..d4a46d8 100644 --- a/poc4_active_source_20260714/tests/test_auth_gateway.py +++ b/ai-web-agent-console/tests/test_auth_gateway.py @@ -13,7 +13,7 @@ from urllib.parse import urlencode ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from src.agent_console.auth_gateway import ( # noqa: E402 +from ai_web_agent_console.auth_gateway import ( # noqa: E402 AuthConfig, LoginAttemptLimiter, SESSION_COOKIE_NAME, diff --git a/ai-web-agent-console/tests/test_query_contracts.py b/ai-web-agent-console/tests/test_query_contracts.py new file mode 100644 index 0000000..f25a707 --- /dev/null +++ b/ai-web-agent-console/tests/test_query_contracts.py @@ -0,0 +1,181 @@ +import unittest + +from ai_web_agent_console.query_contracts import ( + append_query_contract_guidance, + evidence_contract_report, + matching_query_contracts, +) + + +def test_leave_eligibility_question_selects_external_contract(): + contracts = matching_query_contracts( + "직원 E9999의 연차 잔여와 신청 가능 여부를 알려줘", + "search_hr_data", + ) + + assert [contract["id"] for contract in contracts] == [ + "leave_balance_eligibility" + ] + + +def test_unrelated_tool_does_not_receive_leave_contract(): + contracts = matching_query_contracts( + "직원 E9999의 연차 잔여를 알려줘", + "search_carrier_performance", + ) + + assert contracts == () + + +def test_incomplete_result_cannot_be_interpreted_as_zero_balance(): + contracts = matching_query_contracts( + "직원 E9999의 연차 사용 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT: {"items":[{"EMPLOYEE_CODE":"E9999",' + '"REMAINING_DAYS":0}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is False + assert "AVAILABLE_REMAINING_DAYS" in report[0]["missing_fields"] + assert "LEAVE_YEAR" in report[0]["missing_fields"] + + +def test_complete_database_row_satisfies_contract_without_fixture_values(): + contracts = matching_query_contracts( + "직원 E9999의 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":14}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is True + assert report[0]["validated_record"]["LEAVE_YEAR"] == 2031 + assert report[0]["validated_record"]["AVAILABLE_REMAINING_DAYS"] == 14 + + +def test_conflicting_computed_value_fails_contract(): + contracts = matching_query_contracts( + "직원 E9999의 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":0}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is False + assert report[0]["computed_field_checks"][0]["expected"] == "14" + assert report[0]["computed_field_checks"][0]["actual"] == "0" + + +def test_historical_period_rejects_yes_no_decision_and_missing_counts(): + contracts = matching_query_contracts( + "직원 E9999의 과거 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":14,' + '"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",' + '"CAN_APPLY":"YES","INCLUSIVE_CALENDAR_DAYS":11}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is False + failed = { + check["check"] + for check in report[0]["temporal_contract_checks"] + if not check["satisfied"] + } + assert failed == { + "past_period_decision", + "no_past_period_yes_no_decision", + "date_range_counts", + } + + +def test_historical_period_contract_accepts_non_decision_status(): + contracts = matching_query_contracts( + "직원 E9999의 과거 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":14,' + '"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",' + '"APPLICATION_DECISION_STATUS":' + '"NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",' + '"INCLUSIVE_CALENDAR_DAYS":11,' + '"MONDAY_TO_FRIDAY_DAYS":7}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is True + + +def test_guidance_is_appended_from_json_contract(): + guided = append_query_contract_guidance( + "직원 E9999의 휴가 잔여를 조회해줘", + original_question="직원 E9999의 휴가 잔여를 조회해줘", + tool_name="search_hr_data", + ) + + assert '"id":"leave_balance_eligibility"' in guided + assert "AVAILABLE_REMAINING_DAYS" in guided + + +class QueryContractTest(unittest.TestCase): + test_leave_eligibility_question_selects_external_contract = staticmethod( + test_leave_eligibility_question_selects_external_contract + ) + test_unrelated_tool_does_not_receive_leave_contract = staticmethod( + test_unrelated_tool_does_not_receive_leave_contract + ) + test_incomplete_result_cannot_be_interpreted_as_zero_balance = staticmethod( + test_incomplete_result_cannot_be_interpreted_as_zero_balance + ) + test_complete_database_row_satisfies_contract_without_fixture_values = ( + staticmethod(test_complete_database_row_satisfies_contract_without_fixture_values) + ) + test_conflicting_computed_value_fails_contract = staticmethod( + test_conflicting_computed_value_fails_contract + ) + test_historical_period_rejects_yes_no_decision_and_missing_counts = ( + staticmethod(test_historical_period_rejects_yes_no_decision_and_missing_counts) + ) + test_historical_period_contract_accepts_non_decision_status = staticmethod( + test_historical_period_contract_accepts_non_decision_status + ) + test_guidance_is_appended_from_json_contract = staticmethod( + test_guidance_is_appended_from_json_contract + ) diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py similarity index 86% rename from poc4_active_source_20260714/tests/test_scenarios.py rename to ai-web-agent-console/tests/test_scenarios.py index d819430..7c0b3e6 100644 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -6,27 +6,37 @@ import tempfile import unittest from unittest.mock import patch -from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios -from src.agent_console.profile import load_app_profile -from src.mcp_tool_router import McpTool, build_mcp_tool_arguments -from src.mcp_result import ( +from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios +from ai_web_agent_console.profile import load_app_profile +from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments +from ai_web_agent_console.mcp_result import ( has_actionable_text_result, status_result_evidence, status_result_summary, ) +from ai_web_agent_console.model_registry import load_model_registry -def _load_poc4_query_helpers(): +def _load_console_query_helpers(): """Load the Streamlit entrypoint only when its optional runtime is installed.""" try: - from apps.poc4.mcp_discovery_ui import _prepare_hmm_hr_tool_query + from app import _prepare_hmm_hr_tool_query except ModuleNotFoundError: return None return _prepare_hmm_hr_tool_query class DemoScenarioConfigTest(unittest.TestCase): + def test_model_registry_uses_console_names(self) -> None: + registry = load_model_registry() + + self.assertEqual( + registry.registry_name, + "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES", + ) + self.assertTrue(registry.default_profile.default_for_console) + def test_profile_environment_overrides_json_defaults(self) -> None: path = Path(__file__).parents[1] / "config" / "app_profile.json" with patch.dict( @@ -54,7 +64,7 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(profile.short_name, "HMM") def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None: - path = Path(__file__).parents[1] / "src" / "agent_console" / "presentation.py" + path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py" source = path.read_text(encoding="utf-8") self.assertIn('[data-testid="stAppViewContainer"] li', source) @@ -66,17 +76,20 @@ class DemoScenarioConfigTest(unittest.TestCase): def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None: root = Path(__file__).parents[1] - entrypoint = (root / "apps" / "poc4" / "mcp_discovery_ui.py").read_text( + entrypoint = (root / "app.py").read_text( encoding="utf-8" ) - renderer = (root / "src" / "agent_console" / "audit.py").read_text( + renderer = (root / "ai_web_agent_console" / "audit.py").read_text( encoding="utf-8" ) self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint) self.assertIn("_load_hmm_audit_inventory", entrypoint) self.assertIn("(protocol=tcps)(port=1521)", entrypoint) - self.assertIn("POC4_AUDIT_WALLET_PASSWORD", entrypoint) + self.assertIn( + "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD", + entrypoint, + ) self.assertIn("HMM 접근 관리", renderer) self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint) @@ -197,9 +210,9 @@ class DemoScenarioConfigTest(unittest.TestCase): has_actionable_text_result({"status": "success", "result": "No data found"}) ) - @unittest.skipIf(_load_poc4_query_helpers() is None, "Streamlit runtime is optional") + @unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional") def test_policy_query_does_not_include_demo_user_context(self) -> None: - prepare = _load_poc4_query_helpers() + prepare = _load_console_query_helpers() assert prepare is not None tool = McpTool( name="search_hr_policy", diff --git a/sql/adb/00_cleanup.sql b/database/adb/00_cleanup.sql similarity index 100% rename from sql/adb/00_cleanup.sql rename to database/adb/00_cleanup.sql diff --git a/sql/adb/01_dblinks.sql b/database/adb/01_dblinks.sql similarity index 100% rename from sql/adb/01_dblinks.sql rename to database/adb/01_dblinks.sql diff --git a/sql/adb/02_perm_tables.sql b/database/adb/02_perm_tables.sql similarity index 100% rename from sql/adb/02_perm_tables.sql rename to database/adb/02_perm_tables.sql diff --git a/sql/adb/03_seed.sql b/database/adb/03_seed.sql similarity index 98% rename from sql/adb/03_seed.sql rename to database/adb/03_seed.sql index 9223f3e..8205191 100644 --- a/sql/adb/03_seed.sql +++ b/database/adb/03_seed.sql @@ -80,7 +80,7 @@ COMMIT; -- COMMIT; -- -- The policy function vpd_region_filter handles CSV → IN-list --- conversion automatically. See sql/adb/06_policy.sql. +-- conversion automatically. See database/adb/06_policy.sql. -- ------------------------------------------------------------ PROMPT === Seed complete === diff --git a/sql/adb/04_secure_ctx.sql b/database/adb/04_secure_ctx.sql similarity index 100% rename from sql/adb/04_secure_ctx.sql rename to database/adb/04_secure_ctx.sql diff --git a/sql/adb/05_views.sql b/database/adb/05_views.sql similarity index 100% rename from sql/adb/05_views.sql rename to database/adb/05_views.sql diff --git a/sql/adb/06_policy.sql b/database/adb/06_policy.sql similarity index 100% rename from sql/adb/06_policy.sql rename to database/adb/06_policy.sql diff --git a/sql/adb/06a_redaction.sql b/database/adb/06a_redaction.sql similarity index 100% rename from sql/adb/06a_redaction.sql rename to database/adb/06a_redaction.sql diff --git a/sql/adb/07_end_users.sql b/database/adb/07_end_users.sql similarity index 100% rename from sql/adb/07_end_users.sql rename to database/adb/07_end_users.sql diff --git a/sql/adb/08_tests_user_my.sql b/database/adb/08_tests_user_my.sql similarity index 100% rename from sql/adb/08_tests_user_my.sql rename to database/adb/08_tests_user_my.sql diff --git a/sql/adb/09_tests_user_pg.sql b/database/adb/09_tests_user_pg.sql similarity index 100% rename from sql/adb/09_tests_user_pg.sql rename to database/adb/09_tests_user_pg.sql diff --git a/sql/adb/10_tests_user_both.sql b/database/adb/10_tests_user_both.sql similarity index 100% rename from sql/adb/10_tests_user_both.sql rename to database/adb/10_tests_user_both.sql diff --git a/sql/adb/11_tests_user_none.sql b/database/adb/11_tests_user_none.sql similarity index 100% rename from sql/adb/11_tests_user_none.sql rename to database/adb/11_tests_user_none.sql diff --git a/sql/adb/12_tests_admin_audit.sql b/database/adb/12_tests_admin_audit.sql similarity index 100% rename from sql/adb/12_tests_admin_audit.sql rename to database/adb/12_tests_admin_audit.sql diff --git a/sql/adb/13_dds_variant.sql b/database/adb/13_dds_variant.sql similarity index 100% rename from sql/adb/13_dds_variant.sql rename to database/adb/13_dds_variant.sql diff --git a/sql/adb/14_tests_dds_user.sql b/database/adb/14_tests_dds_user.sql similarity index 100% rename from sql/adb/14_tests_dds_user.sql rename to database/adb/14_tests_dds_user.sql diff --git a/sql/adb/15_dds_cleanup.sql b/database/adb/15_dds_cleanup.sql similarity index 100% rename from sql/adb/15_dds_cleanup.sql rename to database/adb/15_dds_cleanup.sql diff --git a/sql/adb/16_agent_ords_security_local_cleanup.sql b/database/adb/16_agent_ords_security_local_cleanup.sql similarity index 100% rename from sql/adb/16_agent_ords_security_local_cleanup.sql rename to database/adb/16_agent_ords_security_local_cleanup.sql diff --git a/sql/adb/17_agent_ords_security_local_vpd_setup.sql b/database/adb/17_agent_ords_security_local_vpd_setup.sql similarity index 100% rename from sql/adb/17_agent_ords_security_local_vpd_setup.sql rename to database/adb/17_agent_ords_security_local_vpd_setup.sql diff --git a/sql/adb/18_agent_ords_security_local_vpd_test.sql b/database/adb/18_agent_ords_security_local_vpd_test.sql similarity index 100% rename from sql/adb/18_agent_ords_security_local_vpd_test.sql rename to database/adb/18_agent_ords_security_local_vpd_test.sql diff --git a/sql/adb/19_agent_ords_security_local_dds_setup.sql b/database/adb/19_agent_ords_security_local_dds_setup.sql similarity index 100% rename from sql/adb/19_agent_ords_security_local_dds_setup.sql rename to database/adb/19_agent_ords_security_local_dds_setup.sql diff --git a/sql/adb/20_agent_ords_security_local_dds_test.sql b/database/adb/20_agent_ords_security_local_dds_test.sql similarity index 100% rename from sql/adb/20_agent_ords_security_local_dds_test.sql rename to database/adb/20_agent_ords_security_local_dds_test.sql diff --git a/sql/adb/21_agent_ords_security_ords_enable_schema.sql b/database/adb/21_agent_ords_security_ords_enable_schema.sql similarity index 100% rename from sql/adb/21_agent_ords_security_ords_enable_schema.sql rename to database/adb/21_agent_ords_security_ords_enable_schema.sql diff --git a/sql/adb/22_agent_ords_security_ords_handler_setup.sql b/database/adb/22_agent_ords_security_ords_handler_setup.sql similarity index 100% rename from sql/adb/22_agent_ords_security_ords_handler_setup.sql rename to database/adb/22_agent_ords_security_ords_handler_setup.sql diff --git a/sql/adb/23_agent_ords_security_ords_handler_test.sql b/database/adb/23_agent_ords_security_ords_handler_test.sql similarity index 100% rename from sql/adb/23_agent_ords_security_ords_handler_test.sql rename to database/adb/23_agent_ords_security_ords_handler_test.sql diff --git a/sql/adb/24_agent_ords_security_inventory.sql b/database/adb/24_agent_ords_security_inventory.sql similarity index 100% rename from sql/adb/24_agent_ords_security_inventory.sql rename to database/adb/24_agent_ords_security_inventory.sql diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/database/adb/25_agent_ords_security_backoffice_support.sql similarity index 100% rename from sql/adb/25_agent_ords_security_backoffice_support.sql rename to database/adb/25_agent_ords_security_backoffice_support.sql diff --git a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql b/database/adb/26_agent_ords_security_dynamic_vpd_filter.sql similarity index 100% rename from sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql rename to database/adb/26_agent_ords_security_dynamic_vpd_filter.sql diff --git a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql b/database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql similarity index 99% rename from sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql rename to database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql index 8b6b1e2..649c7e5 100644 --- a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql +++ b/database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql @@ -3,7 +3,7 @@ -- Unit-style checks for cb_agent_doc_vpd_filter. -- -- Run after: --- @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +-- @database/adb/26_agent_ords_security_dynamic_vpd_filter.sql -- ============================================================ WHENEVER SQLERROR EXIT SQL.SQLCODE SET ECHO OFF diff --git a/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql b/database/adb/28_agent_ords_vector_tag_vpd_setup.sql similarity index 100% rename from sql/adb/28_agent_ords_vector_tag_vpd_setup.sql rename to database/adb/28_agent_ords_vector_tag_vpd_setup.sql diff --git a/sql/adb/29_agent_ords_vector_search_ords.sql b/database/adb/29_agent_ords_vector_search_ords.sql similarity index 100% rename from sql/adb/29_agent_ords_vector_search_ords.sql rename to database/adb/29_agent_ords_vector_search_ords.sql diff --git a/sql/adb/30_agent_ords_vector_tag_vpd_test.sql b/database/adb/30_agent_ords_vector_tag_vpd_test.sql similarity index 100% rename from sql/adb/30_agent_ords_vector_tag_vpd_test.sql rename to database/adb/30_agent_ords_vector_tag_vpd_test.sql diff --git a/sql/adb/31_agent_ords_backoffice_ux_metadata.sql b/database/adb/31_agent_ords_backoffice_ux_metadata.sql similarity index 100% rename from sql/adb/31_agent_ords_backoffice_ux_metadata.sql rename to database/adb/31_agent_ords_backoffice_ux_metadata.sql diff --git a/sql/adb/31_dds_standalone_demo_setup.sql b/database/adb/31_dds_standalone_demo_setup.sql similarity index 100% rename from sql/adb/31_dds_standalone_demo_setup.sql rename to database/adb/31_dds_standalone_demo_setup.sql diff --git a/sql/adb/32_dds_vector_tag_setup.sql b/database/adb/32_dds_vector_tag_setup.sql similarity index 100% rename from sql/adb/32_dds_vector_tag_setup.sql rename to database/adb/32_dds_vector_tag_setup.sql diff --git a/sql/adb/33_agent_ords_sql_trace_grant.sql b/database/adb/33_agent_ords_sql_trace_grant.sql similarity index 100% rename from sql/adb/33_agent_ords_sql_trace_grant.sql rename to database/adb/33_agent_ords_sql_trace_grant.sql diff --git a/sql/adb/34_agent_ords_execution_evidence_grant.sql b/database/adb/34_agent_ords_execution_evidence_grant.sql similarity index 100% rename from sql/adb/34_agent_ords_execution_evidence_grant.sql rename to database/adb/34_agent_ords_execution_evidence_grant.sql diff --git a/sql/adb/34_dds_token_data_grant_common_auth.sql b/database/adb/34_dds_token_data_grant_common_auth.sql similarity index 100% rename from sql/adb/34_dds_token_data_grant_common_auth.sql rename to database/adb/34_dds_token_data_grant_common_auth.sql diff --git a/sql/adb/35_dds_token_data_grant_cleanup.sql b/database/adb/35_dds_token_data_grant_cleanup.sql similarity index 100% rename from sql/adb/35_dds_token_data_grant_cleanup.sql rename to database/adb/35_dds_token_data_grant_cleanup.sql diff --git a/sql/adb/36_dds_sales_knowledge_scenario.sql b/database/adb/36_dds_sales_knowledge_scenario.sql similarity index 100% rename from sql/adb/36_dds_sales_knowledge_scenario.sql rename to database/adb/36_dds_sales_knowledge_scenario.sql diff --git a/sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql b/database/adb/37_dds_sales_knowledge_scenario_cleanup.sql similarity index 100% rename from sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql rename to database/adb/37_dds_sales_knowledge_scenario_cleanup.sql diff --git a/sql/adb/38_agent_ords_vector_all_permission_normalization.sql b/database/adb/38_agent_ords_vector_all_permission_normalization.sql similarity index 100% rename from sql/adb/38_agent_ords_vector_all_permission_normalization.sql rename to database/adb/38_agent_ords_vector_all_permission_normalization.sql diff --git a/sql/adb/39_agent_ords_default_user_display_name.sql b/database/adb/39_agent_ords_default_user_display_name.sql similarity index 100% rename from sql/adb/39_agent_ords_default_user_display_name.sql rename to database/adb/39_agent_ords_default_user_display_name.sql diff --git a/sql/adb/40_dds_protection_evidence.sql b/database/adb/40_dds_protection_evidence.sql similarity index 100% rename from sql/adb/40_dds_protection_evidence.sql rename to database/adb/40_dds_protection_evidence.sql diff --git a/sql/adb/41_dds_row_column_fixture.sql b/database/adb/41_dds_row_column_fixture.sql similarity index 100% rename from sql/adb/41_dds_row_column_fixture.sql rename to database/adb/41_dds_row_column_fixture.sql diff --git a/sql/adb/42_agent_ords_fga_execution_audit.sql b/database/adb/42_agent_ords_fga_execution_audit.sql similarity index 100% rename from sql/adb/42_agent_ords_fga_execution_audit.sql rename to database/adb/42_agent_ords_fga_execution_audit.sql diff --git a/sql/adb/42_dds_fga_execution_evidence.sql b/database/adb/42_dds_fga_execution_evidence.sql similarity index 100% rename from sql/adb/42_dds_fga_execution_evidence.sql rename to database/adb/42_dds_fga_execution_evidence.sql diff --git a/sql/adb/43_agent_ords_probe_id_handler_patch.sql b/database/adb/43_agent_ords_probe_id_handler_patch.sql similarity index 100% rename from sql/adb/43_agent_ords_probe_id_handler_patch.sql rename to database/adb/43_agent_ords_probe_id_handler_patch.sql diff --git a/sql/adb/44_dds_mcp_local_end_user_setup.sql b/database/adb/44_dds_mcp_local_end_user_setup.sql similarity index 100% rename from sql/adb/44_dds_mcp_local_end_user_setup.sql rename to database/adb/44_dds_mcp_local_end_user_setup.sql diff --git a/sql/adb/45_dds_mcp_local_end_user_test.sql b/database/adb/45_dds_mcp_local_end_user_test.sql similarity index 100% rename from sql/adb/45_dds_mcp_local_end_user_test.sql rename to database/adb/45_dds_mcp_local_end_user_test.sql diff --git a/sql/adb/46_kb_stakeholder_token_permission_example.sql b/database/adb/46_kb_stakeholder_token_permission_example.sql similarity index 100% rename from sql/adb/46_kb_stakeholder_token_permission_example.sql rename to database/adb/46_kb_stakeholder_token_permission_example.sql diff --git a/sql/adb/47_kb_stakeholder_permission_or_branches.sql b/database/adb/47_kb_stakeholder_permission_or_branches.sql similarity index 100% rename from sql/adb/47_kb_stakeholder_permission_or_branches.sql rename to database/adb/47_kb_stakeholder_permission_or_branches.sql diff --git a/sql/adb/48_kb_permission_condition_codes.sql b/database/adb/48_kb_permission_condition_codes.sql similarity index 100% rename from sql/adb/48_kb_permission_condition_codes.sql rename to database/adb/48_kb_permission_condition_codes.sql diff --git a/sql/adb/49_permission_static_sql_condition.sql b/database/adb/49_permission_static_sql_condition.sql similarity index 100% rename from sql/adb/49_permission_static_sql_condition.sql rename to database/adb/49_permission_static_sql_condition.sql diff --git a/sql/adb/51_kb_vpd_admin_token.sql b/database/adb/51_kb_vpd_admin_token.sql similarity index 100% rename from sql/adb/51_kb_vpd_admin_token.sql rename to database/adb/51_kb_vpd_admin_token.sql diff --git a/sql/adb/52_cleanup_to_kb_vpd_poc.sql b/database/adb/52_cleanup_to_kb_vpd_poc.sql similarity index 100% rename from sql/adb/52_cleanup_to_kb_vpd_poc.sql rename to database/adb/52_cleanup_to_kb_vpd_poc.sql diff --git a/sql/adb/53_drop_legacy_poc_objects.sql b/database/adb/53_drop_legacy_poc_objects.sql similarity index 100% rename from sql/adb/53_drop_legacy_poc_objects.sql rename to database/adb/53_drop_legacy_poc_objects.sql diff --git a/sql/adb/54_drop_legacy_dds_ords_examples.sql b/database/adb/54_drop_legacy_dds_ords_examples.sql similarity index 100% rename from sql/adb/54_drop_legacy_dds_ords_examples.sql rename to database/adb/54_drop_legacy_dds_ords_examples.sql diff --git a/sql/adb/55_reapply_kb_vpd_after_table_reload.sql b/database/adb/55_reapply_kb_vpd_after_table_reload.sql similarity index 100% rename from sql/adb/55_reapply_kb_vpd_after_table_reload.sql rename to database/adb/55_reapply_kb_vpd_after_table_reload.sql diff --git a/sql/adb/56_kb_select_ai_router_team.sql b/database/adb/56_kb_select_ai_router_team.sql similarity index 100% rename from sql/adb/56_kb_select_ai_router_team.sql rename to database/adb/56_kb_select_ai_router_team.sql diff --git a/sql/adb/57_kb_select_ai_router_team_test.sql b/database/adb/57_kb_select_ai_router_team_test.sql similarity index 100% rename from sql/adb/57_kb_select_ai_router_team_test.sql rename to database/adb/57_kb_select_ai_router_team_test.sql diff --git a/sql/adb/58_kb_select_ai_router_api.sql b/database/adb/58_kb_select_ai_router_api.sql similarity index 100% rename from sql/adb/58_kb_select_ai_router_api.sql rename to database/adb/58_kb_select_ai_router_api.sql diff --git a/sql/adb/59_kb_select_ai_router_ords.sql b/database/adb/59_kb_select_ai_router_ords.sql similarity index 100% rename from sql/adb/59_kb_select_ai_router_ords.sql rename to database/adb/59_kb_select_ai_router_ords.sql diff --git a/sql/adb/60_kb_vpd_admin_full_access_token.sql b/database/adb/60_kb_vpd_admin_full_access_token.sql similarity index 100% rename from sql/adb/60_kb_vpd_admin_full_access_token.sql rename to database/adb/60_kb_vpd_admin_full_access_token.sql diff --git a/sql/adb/62_kb_aso_masking_backoffice_metadata.sql b/database/adb/62_kb_aso_masking_backoffice_metadata.sql similarity index 100% rename from sql/adb/62_kb_aso_masking_backoffice_metadata.sql rename to database/adb/62_kb_aso_masking_backoffice_metadata.sql diff --git a/sql/adb/63_kb_aso_masking_rule_runtime.sql b/database/adb/63_kb_aso_masking_rule_runtime.sql similarity index 100% rename from sql/adb/63_kb_aso_masking_rule_runtime.sql rename to database/adb/63_kb_aso_masking_rule_runtime.sql diff --git a/sql/adb/64_kb_aso_masking_default_column_rules.sql b/database/adb/64_kb_aso_masking_default_column_rules.sql similarity index 100% rename from sql/adb/64_kb_aso_masking_default_column_rules.sql rename to database/adb/64_kb_aso_masking_default_column_rules.sql diff --git a/sql/adb/65_kb_select_ai_vpd_query_api.sql b/database/adb/65_kb_select_ai_vpd_query_api.sql similarity index 100% rename from sql/adb/65_kb_select_ai_vpd_query_api.sql rename to database/adb/65_kb_select_ai_vpd_query_api.sql diff --git a/sql/adb/66_kb_select_ai_vpd_query_ords.sql b/database/adb/66_kb_select_ai_vpd_query_ords.sql similarity index 100% rename from sql/adb/66_kb_select_ai_vpd_query_ords.sql rename to database/adb/66_kb_select_ai_vpd_query_ords.sql diff --git a/sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql b/database/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql similarity index 100% rename from sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql rename to database/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql diff --git a/sql/adb/70_sg_tool_user.sql b/database/adb/70_sg_tool_user.sql similarity index 100% rename from sql/adb/70_sg_tool_user.sql rename to database/adb/70_sg_tool_user.sql diff --git a/sql/adb/71_sg_identity_administration.sql b/database/adb/71_sg_identity_administration.sql similarity index 100% rename from sql/adb/71_sg_identity_administration.sql rename to database/adb/71_sg_identity_administration.sql diff --git a/sql/adb/72_hmm_leave_team_vpd.sql b/database/adb/72_hmm_leave_team_vpd.sql similarity index 100% rename from sql/adb/72_hmm_leave_team_vpd.sql rename to database/adb/72_hmm_leave_team_vpd.sql diff --git a/sql/adb/73_hmm_mcp_vpd_runtime.sql b/database/adb/73_hmm_mcp_vpd_runtime.sql similarity index 100% rename from sql/adb/73_hmm_mcp_vpd_runtime.sql rename to database/adb/73_hmm_mcp_vpd_runtime.sql diff --git a/sql/adb/73_hmm_rds_pg_00_precheck.sql b/database/adb/73_hmm_rds_pg_00_precheck.sql similarity index 100% rename from sql/adb/73_hmm_rds_pg_00_precheck.sql rename to database/adb/73_hmm_rds_pg_00_precheck.sql diff --git a/sql/adb/74_hmm_rds_pg_01_credential.sql b/database/adb/74_hmm_rds_pg_01_credential.sql similarity index 100% rename from sql/adb/74_hmm_rds_pg_01_credential.sql rename to database/adb/74_hmm_rds_pg_01_credential.sql diff --git a/sql/adb/75_hmm_rds_pg_02_acl.sql b/database/adb/75_hmm_rds_pg_02_acl.sql similarity index 100% rename from sql/adb/75_hmm_rds_pg_02_acl.sql rename to database/adb/75_hmm_rds_pg_02_acl.sql diff --git a/sql/adb/76_hmm_rds_pg_03_dblink.sql b/database/adb/76_hmm_rds_pg_03_dblink.sql similarity index 100% rename from sql/adb/76_hmm_rds_pg_03_dblink.sql rename to database/adb/76_hmm_rds_pg_03_dblink.sql diff --git a/sql/adb/77_hmm_rds_pg_04_remote_query.sql b/database/adb/77_hmm_rds_pg_04_remote_query.sql similarity index 100% rename from sql/adb/77_hmm_rds_pg_04_remote_query.sql rename to database/adb/77_hmm_rds_pg_04_remote_query.sql diff --git a/sql/adb/78_hmm_rds_pg_05_views.sql b/database/adb/78_hmm_rds_pg_05_views.sql similarity index 100% rename from sql/adb/78_hmm_rds_pg_05_views.sql rename to database/adb/78_hmm_rds_pg_05_views.sql diff --git a/sql/adb/79_hmm_rds_pg_06_verify.sql b/database/adb/79_hmm_rds_pg_06_verify.sql similarity index 100% rename from sql/adb/79_hmm_rds_pg_06_verify.sql rename to database/adb/79_hmm_rds_pg_06_verify.sql diff --git a/sql/adb/80_hmm_rds_pg_99_cleanup.sql b/database/adb/80_hmm_rds_pg_99_cleanup.sql similarity index 100% rename from sql/adb/80_hmm_rds_pg_99_cleanup.sql rename to database/adb/80_hmm_rds_pg_99_cleanup.sql diff --git a/sql/adb/81_hmm_carrier_access_groups.sql b/database/adb/81_hmm_carrier_access_groups.sql similarity index 100% rename from sql/adb/81_hmm_carrier_access_groups.sql rename to database/adb/81_hmm_carrier_access_groups.sql diff --git a/sql/adb/82_hmm_federation_relationship_comments.sql b/database/adb/82_hmm_federation_relationship_comments.sql similarity index 100% rename from sql/adb/82_hmm_federation_relationship_comments.sql rename to database/adb/82_hmm_federation_relationship_comments.sql diff --git a/sql/source/mysql_setup.sql b/database/source/mysql_setup.sql similarity index 96% rename from sql/source/mysql_setup.sql rename to database/source/mysql_setup.sql index 82530e6..5eb8e78 100644 --- a/sql/source/mysql_setup.sql +++ b/database/source/mysql_setup.sql @@ -1,5 +1,5 @@ -- ============================================================ --- sql/source/mysql_setup.sql +-- database/source/mysql_setup.sql -- Run on the REMOTE MySQL instance (mysql CLI). -- -- Creates the customers table in the `ecommerce_poc` schema with @@ -12,7 +12,7 @@ -- -- 호출 예시 (run.sh source 가 자동 실행): -- mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" \ --- $MY_DB < sql/source/mysql_setup.sql +-- $MY_DB < database/source/mysql_setup.sql -- ============================================================ SELECT '=== Creating ecommerce_poc.customers (idempotent) ===' AS status; diff --git a/sql/source/postgres_hmm_carrier_performance.sql b/database/source/postgres_hmm_carrier_performance.sql similarity index 98% rename from sql/source/postgres_hmm_carrier_performance.sql rename to database/source/postgres_hmm_carrier_performance.sql index 509895b..db766f6 100644 --- a/sql/source/postgres_hmm_carrier_performance.sql +++ b/database/source/postgres_hmm_carrier_performance.sql @@ -13,7 +13,7 @@ -- PGPASSWORD="$PG_PASSWORD" psql \ -- "host=$PG_HOST port=5432 dbname=postgres user=$PG_USER \ -- sslmode=verify-full sslrootcert=$PG_SSL_ROOT_CERT" \ --- -X -f sql/source/postgres_hmm_carrier_performance.sql +-- -X -f database/source/postgres_hmm_carrier_performance.sql -- ============================================================ \set ON_ERROR_STOP on @@ -297,4 +297,4 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA hmm_demo COMMIT; \echo === HMM carrier performance demo: complete === -\echo Next: run sql/source/postgres_hmm_carrier_performance_verify.sql +\echo Next: run database/source/postgres_hmm_carrier_performance_verify.sql diff --git a/sql/source/postgres_hmm_carrier_performance_verify.sql b/database/source/postgres_hmm_carrier_performance_verify.sql similarity index 100% rename from sql/source/postgres_hmm_carrier_performance_verify.sql rename to database/source/postgres_hmm_carrier_performance_verify.sql diff --git a/sql/source/postgres_setup.sql b/database/source/postgres_setup.sql similarity index 96% rename from sql/source/postgres_setup.sql rename to database/source/postgres_setup.sql index 332b802..41cd911 100644 --- a/sql/source/postgres_setup.sql +++ b/database/source/postgres_setup.sql @@ -1,5 +1,5 @@ -- ============================================================ --- sql/source/postgres_setup.sql +-- database/source/postgres_setup.sql -- Run on the REMOTE PostgreSQL instance (psql). -- -- Creates the customers table in the `public` schema with sample @@ -10,7 +10,7 @@ -- -- 호출 예시 (run.sh source 가 자동 실행): -- PGPASSWORD=$PG_PASSWORD psql -h $PG_HOST -U $PG_USER -d $PG_DB \ --- -f sql/source/postgres_setup.sql +-- -f database/source/postgres_setup.sql -- ============================================================ \echo === Creating public.customers (idempotent) === diff --git a/dds-backoffice/README.md b/dds-backoffice/README.md index cd9d665..3c62f4c 100644 --- a/dds-backoffice/README.md +++ b/dds-backoffice/README.md @@ -56,10 +56,10 @@ ORDS Handler가 Bearer 값을 문자열로 바꾸는 것만으로 DDS Context가 먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다. ```bash -sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup.sql -sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql +sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/31_dds_standalone_demo_setup.sql +sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/32_dds_vector_tag_setup.sql bash scripts/setup-dds-token-data-grant.sh -sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/36_dds_sales_knowledge_scenario.sql +sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/36_dds_sales_knowledge_scenario.sql ``` `32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다. @@ -68,7 +68,7 @@ sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/36_dds_sales_knowledge_scena `36_dds_sales_knowledge_scenario.sql`은 `agent_sales`와 `SALES_KNOWLEDGE_ROLE`을 만들고 `TECH_TAG=SALES`인 세일즈 청크(28004)를 추가합니다. 짧은 데모 토큰 `dds_sales_demo_token`을 사용하면 결과는 28004만 남아야 합니다. 운영에서는 이 토큰 대신 Bearer 관리 화면에서 발급한 임시 토큰을 사용합니다. -토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql`과 `sql/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다. +토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `database/adb/37_dds_sales_knowledge_scenario_cleanup.sql`과 `database/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다. 그 다음 DDS 인스턴스를 실행합니다. diff --git a/poc4_active_source_20260714/infra/nginx/hmm-portal-auth.locations.conf b/deploy/ai-web-agent-console/nginx/hmm-portal-auth.locations.conf similarity index 100% rename from poc4_active_source_20260714/infra/nginx/hmm-portal-auth.locations.conf rename to deploy/ai-web-agent-console/nginx/hmm-portal-auth.locations.conf diff --git a/poc4_active_source_20260714/deploy/systemd/poc4-streamlit-audit.conf b/deploy/ai-web-agent-console/systemd/ai-web-agent-console-audit.conf similarity index 100% rename from poc4_active_source_20260714/deploy/systemd/poc4-streamlit-audit.conf rename to deploy/ai-web-agent-console/systemd/ai-web-agent-console-audit.conf diff --git a/poc4_active_source_20260714/infra/systemd/hmm-portal-auth.service b/deploy/ai-web-agent-console/systemd/hmm-portal-auth.service similarity index 88% rename from poc4_active_source_20260714/infra/systemd/hmm-portal-auth.service rename to deploy/ai-web-agent-console/systemd/hmm-portal-auth.service index 80899ee..bc9e67f 100644 --- a/poc4_active_source_20260714/infra/systemd/hmm-portal-auth.service +++ b/deploy/ai-web-agent-console/systemd/hmm-portal-auth.service @@ -10,7 +10,7 @@ Group=opc WorkingDirectory=/opt/hmm-poc4 Environment=PYTHONUNBUFFERED=1 EnvironmentFile=/opt/hmm-poc4/.env -ExecStart=/opt/hmm-poc4/.venv/bin/python -m src.agent_console.auth_gateway +ExecStart=/opt/hmm-poc4/.venv/bin/python -m ai_web_agent_console.auth_gateway Restart=on-failure RestartSec=3 NoNewPrivileges=true diff --git a/deploy/hmm-backoffice.env.example b/deploy/vpd-backoffice/backoffice.env.example similarity index 100% rename from deploy/hmm-backoffice.env.example rename to deploy/vpd-backoffice/backoffice.env.example diff --git a/deploy/caddy/Caddyfile.template b/deploy/vpd-backoffice/caddy/Caddyfile.template similarity index 100% rename from deploy/caddy/Caddyfile.template rename to deploy/vpd-backoffice/caddy/Caddyfile.template diff --git a/deploy/caddy/install-caddy-config.sh b/deploy/vpd-backoffice/caddy/install-caddy-config.sh similarity index 100% rename from deploy/caddy/install-caddy-config.sh rename to deploy/vpd-backoffice/caddy/install-caddy-config.sh diff --git a/deploy/systemd/vpd-backoffice-structured-data.conf b/deploy/vpd-backoffice/vpd-backoffice-structured-data.conf similarity index 100% rename from deploy/systemd/vpd-backoffice-structured-data.conf rename to deploy/vpd-backoffice/vpd-backoffice-structured-data.conf diff --git a/docs/03-detailed-guide.md b/docs/03-detailed-guide.md index 5a0335f..806aecd 100644 --- a/docs/03-detailed-guide.md +++ b/docs/03-detailed-guide.md @@ -8,7 +8,7 @@ > 단위 매트릭스** 입니다 (README 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행 > 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로 > 사용합니다 — VPD 메커니즘 자체는 동일하므로 개념 이해에는 차이가 없습니다. -> region 필터를 실제로 켜려면 `sql/adb/03_seed.sql` 하단의 주석을 해제하세요. +> region 필터를 실제로 켜려면 `database/adb/03_seed.sql` 하단의 주석을 해제하세요. --- @@ -543,7 +543,7 @@ SQL> SELECT COUNT(*) FROM admin.v_customers_pg; -- 0 SQL> SELECT COUNT(*) FROM admin.v_customers_my; -- 0 # region 필터 변형을 켰을 때 — vpduser_both 에게 APAC 만 허용한 경우 -# (sql/adb/03_seed.sql 하단 UPDATE 주석 해제 후) +# (database/adb/03_seed.sql 하단 UPDATE 주석 해제 후) sqlplus "vpduser_both/\"${VPDUSER_BOTH_PASSWORD}\"@$ADB_TNS" SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region; -- 결과: APAC 만 보임 diff --git a/docs/04-source-setup.md b/docs/04-source-setup.md index ffb49cb..d68e785 100644 --- a/docs/04-source-setup.md +++ b/docs/04-source-setup.md @@ -4,7 +4,7 @@ --- -## Postgres (`sql/source/postgres_setup.sql`) +## Postgres (`database/source/postgres_setup.sql`) ``` DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc) @@ -22,12 +22,12 @@ seed rows : 12 (APAC 4 / EMEA 4 / AMER 4) ```bash PGPASSWORD=$PG_PASSWORD psql \ -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ - -f sql/source/postgres_setup.sql + -f database/source/postgres_setup.sql ``` --- -## MySQL (`sql/source/mysql_setup.sql`) +## MySQL (`database/source/mysql_setup.sql`) ``` DB : MY_DB (기본 ecommerce_poc) @@ -47,12 +47,12 @@ PK 범위를 PG (1~12) 와 다르게 가져간 이유: ```bash mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" $MY_DB \ - < sql/source/mysql_setup.sql + < database/source/mysql_setup.sql ``` --- -## ADB → 원격 DB Link (`sql/adb/01_dblinks.sql`) +## ADB → 원격 DB Link (`database/adb/01_dblinks.sql`) `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을 처리합니다. 별도 Database Gateway 설치 불필요. diff --git a/docs/05-dds-variant.md b/docs/05-dds-variant.md index f6a35bf..8c835fc 100644 --- a/docs/05-dds-variant.md +++ b/docs/05-dds-variant.md @@ -2,7 +2,7 @@ > 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로 > 그대로 동작합니다. 이 문서는 동일한 4-user 매트릭스를 Oracle AI Database -> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `sql/adb/13_dds_variant.sql` +> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `database/adb/13_dds_variant.sql` > 의 사용법과, VPD ↔ DDS 1:1 매핑을 다룹니다. > > 본 변형은 **2026-05-26 실제 ADB (23.26.2.2.0) 에서 E2E 검증** 됨 (8장 결과). @@ -84,7 +84,7 @@ Oracle 이 **2026-04-09** 에 VPD/RAS 의 공식 후계자로 발표한 native a ```bash source .env # DDSUSER_*_PASSWORD 로드 -sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/13_dds_variant.sql +sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/13_dds_variant.sql ``` 스크립트가 만드는 객체: @@ -218,7 +218,7 @@ DROP VIEW v_dds_customers_my; ## 8. E2E 검증 결과 (2026-05-26, ADB 23.26.2.2.0) -`sql/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회: +`database/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회: | 사용자 | `v_dds_customers_pg` | `v_dds_customers_my` | 판정 | |---|---|---|---| diff --git a/docs/06-agent-ords-vpd-dds-security-brief.md b/docs/06-agent-ords-vpd-dds-security-brief.md index d98cf64..6d0003a 100644 --- a/docs/06-agent-ords-vpd-dds-security-brief.md +++ b/docs/06-agent-ords-vpd-dds-security-brief.md @@ -1165,15 +1165,15 @@ ORDER BY rg.grantee, dg.object_name, dg.grant_name; | 파일 | 역할 | |---|---| | `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 | -| `sql/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 | -| `sql/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 | -| `sql/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 | -| `sql/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 | -| `sql/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 | -| `sql/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable | -| `sql/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 | -| `sql/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 | -| `sql/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 | +| `database/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 | +| `database/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 | +| `database/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 | +| `database/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 | +| `database/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 | +| `database/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable | +| `database/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 | +| `database/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 | +| `database/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 | ### 7.1 VPD + Redaction 실행 검증 @@ -1634,7 +1634,7 @@ ORA-20101: Authorization header must be Bearer ### 7.4 중앙 권한 인벤토리 -VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `sql/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다. +VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `database/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다. VPD 정책 연결 확인: diff --git a/docs/design/424-spring-boot-vpd-backoffice/README.md b/docs/design/424-spring-boot-vpd-backoffice/README.md index 9440080..ee761f2 100644 --- a/docs/design/424-spring-boot-vpd-backoffice/README.md +++ b/docs/design/424-spring-boot-vpd-backoffice/README.md @@ -3,7 +3,7 @@ > **상태**: Draft > **작성**: [AI] Architect · **최종수정**: 2026-06-23 > **추적성** — Redmine: #424 · 관련 ADR: 없음 -> · 구현 파일: `pom.xml`, `src/main/**`, `src/test/**` · 테스트: `mvn test` +> · 구현 파일: `vpd-backoffice/pom.xml`, `vpd-backoffice/src/main/**`, `vpd-backoffice/src/test/**` · 테스트: `mvn test` ## 1. 목적 (Why) @@ -58,8 +58,8 @@ ### 모듈/파일 구조 ```text -pom.xml -src/main/java/com/cloudhandson/vpdbackoffice/ +vpd-backoffice/pom.xml +vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/ VpdBackofficeApplication.java config/ DataSourceConfig.java @@ -88,13 +88,13 @@ src/main/java/com/cloudhandson/vpdbackoffice/ TokenController.java ProbeController.java DashboardController.java -src/main/resources/ +vpd-backoffice/src/main/resources/ application.yml mapper/*.xml templates/**/*.html static/css/app.css static/js/app.js -src/test/java/com/cloudhandson/vpdbackoffice/ +vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/ ``` ### 데이터 흐름 @@ -251,7 +251,7 @@ MCP-style reasoning ## 12. 미해결 질문 (Open Questions) -- ORDS 검증 API는 기존 `sql/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다. +- ORDS 검증 API는 기존 `database/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다. - 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다. - 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다. - 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다. diff --git a/docs/design/456-vpd-predicate-rule-tests/README.md b/docs/design/456-vpd-predicate-rule-tests/README.md index 1f5475d..e27797f 100644 --- a/docs/design/456-vpd-predicate-rule-tests/README.md +++ b/docs/design/456-vpd-predicate-rule-tests/README.md @@ -3,7 +3,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-25 > **추적성** — Redmine: #456 · 관련 ADR: 없음 -> · 구현 파일: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test` +> · 구현 파일: `database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test` ## 1. 목적 (Why) @@ -78,8 +78,8 @@ I/O와 순수 로직 경계: ## 10. 테스트 계획 -- SQLcl: `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql` -- SQLcl: `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` +- SQLcl: `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql` +- SQLcl: `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` - Maven: `mvn test` - ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인. diff --git a/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md b/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md index 9db348a..4148a25 100644 --- a/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md +++ b/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md @@ -1,7 +1,7 @@ # 함수 설계서: `cb_agent_doc_vpd_filter` (#456) > **부모 설계서**: ./README.md · **상태**: Approved -> **작성**: [AI] Architect · **구현**: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` +> **작성**: [AI] Architect · **구현**: `database/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` ## 1. 시그니처 diff --git a/docs/design/462-predicate-injection-defense/README.md b/docs/design/462-predicate-injection-defense/README.md index 2ffe20e..97cb16d 100644 --- a/docs/design/462-predicate-injection-defense/README.md +++ b/docs/design/462-predicate-injection-defense/README.md @@ -3,7 +3,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-25 > **추적성** — Redmine: #462 · 관련 ADR: 없음 -> · 구현 파일: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test` +> · 구현 파일: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test` ## 1. 목적 (Why) @@ -67,8 +67,8 @@ malicious cb_permission_rule rows ## 10. 테스트 계획 -- `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql` -- `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` +- `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql` +- `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` - `./run.sh backoffice-vpd-ords-test` - `mvn test` diff --git a/docs/design/547-vm-https-hardening/README.md b/docs/design/547-vm-https-hardening/README.md index 8a1fbc7..767b5f9 100644 --- a/docs/design/547-vm-https-hardening/README.md +++ b/docs/design/547-vm-https-hardening/README.md @@ -3,7 +3,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-28 > **추적성** — Redmine: #547 · 관련 ADR: 없음 -> · 구현 파일: `src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/caddy/Caddyfile.template` +> · 구현 파일: `vpd-backoffice/src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/vpd-backoffice/caddy/Caddyfile.template` > · 테스트: `TransportSecurityTest.java`, `scripts/test-backoffice-https-config.sh` ## 1. 목적 (Why) @@ -49,7 +49,7 @@ Internet client Internet client ── HTTP :8082 ──X (loopback bind + firewalld/NSG deny) ``` -- `deploy/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정. +- `deploy/vpd-backoffice/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정. - `scripts/configure-backoffice-https-vm.sh`: 입력 검증, 원격 설정 검증·백업·적용·확인. - `scripts/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크. - Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다. diff --git a/docs/design/559-independent-vpd-dds-demos/README.md b/docs/design/559-independent-vpd-dds-demos/README.md index 7cb30bb..2d5775b 100644 --- a/docs/design/559-independent-vpd-dds-demos/README.md +++ b/docs/design/559-independent-vpd-dds-demos/README.md @@ -2,7 +2,7 @@ > **상태**: VPD·DDS 병행 검증 완료, 토큰 기반 객체별 Data Grant 적용 완료 > **추적성**: Redmine #565, #566 · DDS 구현 커밋 `1e48864`, `a5e70bc`, `04cca64`, `8978be4` -> **기준 구현**: `sql/adb/32_dds_vector_tag_setup.sql`, `sql/adb/34_dds_token_data_grant_common_auth.sql`, `sql/adb/36_dds_sales_knowledge_scenario.sql`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java` +> **기준 구현**: `database/adb/32_dds_vector_tag_setup.sql`, `database/adb/34_dds_token_data_grant_common_auth.sql`, `database/adb/36_dds_sales_knowledge_scenario.sql`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java` ## 목적 diff --git a/docs/design/567-ords-vpd-sql-trace/README.md b/docs/design/567-ords-vpd-sql-trace/README.md index af908b4..1ff3520 100644 --- a/docs/design/567-ords-vpd-sql-trace/README.md +++ b/docs/design/567-ords-vpd-sql-trace/README.md @@ -77,8 +77,8 @@ Authorization: Bearer ## 설치와 운영 절차 -1. `ADMIN`으로 [42_agent_ords_fga_execution_audit.sql](../../../sql/adb/42_agent_ords_fga_execution_audit.sql)을 실행해 활성 보호 객체마다 FGA `SELECT` 정책을 만든다. -2. `CB_ORDS`로 [22_agent_ords_security_ords_handler_setup.sql](../../../sql/adb/22_agent_ords_security_ords_handler_setup.sql), [29_agent_ords_vector_search_ords.sql](../../../sql/adb/29_agent_ords_vector_search_ords.sql), [43_agent_ords_probe_id_handler_patch.sql](../../../sql/adb/43_agent_ords_probe_id_handler_patch.sql)을 순서대로 실행한다. +1. `ADMIN`으로 [42_agent_ords_fga_execution_audit.sql](../../../database/adb/42_agent_ords_fga_execution_audit.sql)을 실행해 활성 보호 객체마다 FGA `SELECT` 정책을 만든다. +2. `CB_ORDS`로 [22_agent_ords_security_ords_handler_setup.sql](../../../database/adb/22_agent_ords_security_ords_handler_setup.sql), [29_agent_ords_vector_search_ords.sql](../../../database/adb/29_agent_ords_vector_search_ords.sql), [43_agent_ords_probe_id_handler_patch.sql](../../../database/adb/43_agent_ords_probe_id_handler_patch.sql)을 순서대로 실행한다. 3. 새 권한 결과 확인을 실행한다. 이전 요청에는 `CLIENT_ID`가 없으므로 소급해 매칭하지 않는다. 4. 백오피스 실행 계정이 Autonomous의 `UNIFIED_AUDIT_TRAIL`을 조회할 수 있어야 한다. 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 사용한다. 일반 계정이면 감사 조회 권한을 가진 전용 observer 계정을 사용한다. diff --git a/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md b/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md index 70f229e..a9e4007 100644 --- a/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md +++ b/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md @@ -1,7 +1,7 @@ # 함수 설계서: `addLocalSqlTrace` (#567) > **부모 설계서**: ./README.md · **상태**: Approved -> **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test` +> **작성**: [AI] Architect · **구현**: `vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test` ## 1. 시그니처 diff --git a/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md b/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md index a175bfe..46936d4 100644 --- a/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md +++ b/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md @@ -1,7 +1,7 @@ # 함수 설계서: `findVpdPredicate` (#567) > **부모 설계서**: ./README.md · **상태**: Approved -> **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke +> **작성**: [AI] Architect · **구현**: `vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke ## 1. 시그니처 diff --git a/docs/design/617-dds-mcp-end-user-context/README.md b/docs/design/617-dds-mcp-end-user-context/README.md index 135e862..7ddb6dc 100644 --- a/docs/design/617-dds-mcp-end-user-context/README.md +++ b/docs/design/617-dds-mcp-end-user-context/README.md @@ -4,8 +4,8 @@ > **최종수정**: 2026-07-19 > **추적성** — Redmine: #617 · 관련 ADR: [ADR-0001](../../adr/0001-dds-mcp-service-identity.md), [ADR-0002](../../adr/0002-dds-runtime-permission-source.md) > · 현재 구현: `DdsMcpBearerAuthenticator`, `DdsMcpEndUserResolver`, `DdsMcpContextExecutor`, `DdsMcpSseController` -> · 기존 PoC 게시: `sql/adb/44_dds_mcp_local_end_user_setup.sql` -> · 검증: `sql/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call` +> · 기존 PoC 게시: `database/adb/44_dds_mcp_local_end_user_setup.sql` +> · 검증: `database/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call` ## 1. 결정과 목적 diff --git a/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md b/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md index 2cb0ec7..a920a95 100644 --- a/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md +++ b/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md @@ -3,7 +3,9 @@ > **상태**: Implemented snapshot documented · follow-up contract pending > **작성**: [AI] Architect · **최종수정**: 2026-07-14 > **추적성** — Redmine: #654 · 관련 ADR: 없음 · 구현 커밋: `5bdd242`, `33399f9`, `1f6d974` -> · 스냅샷: `poc4_active_source_20260714/` · 원본 archive: `poc4_active_source_20260714/poc4_active_source_20260714.tar.gz` · 현행 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `src/mcp_tool_router.py`, `src/oci_genai_sdk.py`, `src/poc3/model_registry.py`, `config/mcp_servers.json` · 테스트: Python 3.11 `py_compile`, 기존 백오피스 `mvn -q test` +> · 현재 소스: `ai-web-agent-console/` · 현행 구현 파일: `ai-web-agent-console/app.py`, `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py`, `ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py`, `ai-web-agent-console/ai_web_agent_console/model_registry.py`, `ai-web-agent-console/config/mcp_servers.json` · 테스트: Python 3.11 `compileall`/`unittest`, `mvn -f vpd-backoffice/pom.xml test` +> +> 이 문서의 PoC4 명칭과 원격 archive 경로는 2026-07-14 당시의 이력이다. 현재 저장소 구조는 #742 설계서를 기준으로 한다. ## 1. 목적 (Why) @@ -17,17 +19,17 @@ PoC4 MCP AI Console이 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하 | 영역 | 현행 구현 | |---|---| -| 저장소 위치 | `poc4_active_source_20260714/` 단일 폴더에 격리 | -| 실행 진입점 | 직접 실행은 `streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622` | +| 저장소 위치 | `ai-web-agent-console/` 단일 폴더에 격리 | +| 실행 진입점 | 직접 실행은 `streamlit run ai-web-agent-console/app.py --server.address 0.0.0.0 --server.port 8622` | | Python 런타임 | Python 3.11 이상. 배포 서버 검증 런타임은 `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` | -| Streamlit 화면 | `apps/poc4/mcp_discovery_ui.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 | -| MCP registry | `config/mcp_servers.json`. `kb_mcp`와 `kb_vector_mcp` 두 서버를 선언 | +| Streamlit 화면 | `ai-web-agent-console/app.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 | +| MCP registry | `ai-web-agent-console/config/mcp_servers.json` | | KB 정형 MCP | `https://kb.cloud-handson.com/mcp`, 기본 tool `ords.query.kb_select_ai_vpd` | | KB 벡터 MCP | `http://127.0.0.1:9978/mcp`, allowlist `hybrid_rerank_search` | -| 토큰 preset | `config/vpd_token_presets.json`. 실제 토큰 원문은 포함하지 않고 placeholder만 둠 | +| 토큰 preset | `ai-web-agent-console/config/vpd_token_presets.json`. 실제 토큰 원문은 포함하지 않고 placeholder만 둠 | | 대화 저장소 | 기본 `data/poc4_mcp_chat.sqlite3`, 환경변수 `POC4_CHAT_DB_PATH`로 변경 가능 | | DB evidence 연결 | 기본 env file `/home/opc/kbmcp/.env`, wallet fallback `/home/opc/wallet/kbaipoc` | -| OCI GenAI | `OCI_AUTH_TYPE=config_file`, `~/.oci/config`, `DEFAULT` profile 기반. model profile은 `config/poc3_model_profiles.json`에서 로드 | +| OCI GenAI | `OCI_AUTH_TYPE=config_file`, `~/.oci/config`, `DEFAULT` profile 기반. model profile은 `ai-web-agent-console/config/model_profiles.json`에서 로드 | | 보안 원칙 | wallet, DB password, wallet password, 실제 VPD token, 대화 DB는 저장소에 포함하지 않음 | 현행 데이터 흐름은 다음과 같다. @@ -60,25 +62,24 @@ SQLite 대화 이력 저장 | 파일 | 책임 | |---|---| -| `apps/poc4/mcp_discovery_ui.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 | -| `apps/poc4/ui_theme.py` | UI theme 보조 코드 | -| `src/mcp_tool_router.py` | 발견된 MCP tool descriptor를 기반으로 LLM router가 server/tool을 선택하고 tool arguments를 구성 | -| `src/oci_genai_sdk.py` | OCI Generative AI 호출 경계. MCP/VPD token을 알지 않는 최소 completion client | -| `src/poc3/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 | -| `src/poc3/questions.py` | 데모 질문 목록 | -| `config/mcp_servers.json` | MCP 서버 registry. 현재는 `endpoint_url`이 있으면 이를 우선 사용 | -| `config/poc3_model_profiles.json` | `gpt55_oci`, `gpt54_mini_oci`, `grok43`, `llama4_maverick`, `llama33_70b` profile | -| `config/vpd_token_presets.json` | 데모 사용자 token preset 구조. 실제 토큰은 배포 환경에서 교체 | -| `scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh` | 원본 PoC4 런처 wrapper. 현재 스냅샷의 직접 entrypoint와는 다르게 `/home/opc/poc_4/scripts/poc4/run_8622_langgraph_tc_ui.sh` 및 `apps/poc4/langgraph_tc_ui.py`를 참조 | +| `ai-web-agent-console/app.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 | +| `ai-web-agent-console/ai_web_agent_console/presentation.py` | 공통 UI theme와 화면 표현 보조 코드 | +| `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py` | 발견된 MCP tool descriptor를 기반으로 LLM router가 server/tool을 선택하고 tool arguments를 구성 | +| `ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py` | OCI Generative AI 호출 경계. MCP/VPD token을 알지 않는 최소 completion client | +| `ai-web-agent-console/ai_web_agent_console/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 | +| `ai-web-agent-console/ai_web_agent_console/questions.py` | 데모 질문 목록 | +| `ai-web-agent-console/config/mcp_servers.json` | MCP 서버 registry | +| `ai-web-agent-console/config/model_profiles.json` | OCI GenAI model profile registry | +| `ai-web-agent-console/config/vpd_token_presets.json` | 데모 사용자 token preset 구조. 실제 토큰은 배포 환경에서 교체 | -런처 주의사항: 스냅샷에는 `mcp_discovery_ui.py` 직접 실행에 필요한 소스가 들어 있지만, 포함된 `start_8622...` script는 원본 서버의 공용 runtime wrapper에 의존한다. 이 저장소에서 독립 실행하려면 `SOURCE_README.md`의 직접 `streamlit run` 명령을 사용하거나, 별도 wrapper를 작성해야 한다. +현재 저장소에서는 `cd ai-web-agent-console && streamlit run app.py`를 독립 실행 진입점으로 사용한다. ## 1.3 현행 보안/비밀정보 경계 - `VpdTokenPreset.token`은 dataclass에서 `repr=False`이며, UI는 token을 정규화한 뒤 Authorization header에만 넣는다. - `_NoRedirectHandler`는 redirect 시 Authorization header가 다른 endpoint로 전달되는 것을 막는다. -- `src/mcp_tool_router.py`의 router는 질문과 tool descriptor만 받으며 bearer token 또는 provider credential을 받지 않는다. -- `src/oci_genai_sdk.py`는 `.env`에서 `OCI_AUTH_TYPE`, `OCI_CONFIG_FILE`, `OCI_GENAI_COMPARTMENT_ID`, `OCI_PROFILE`만 읽는다. +- `ai_web_agent_console.mcp_tool_router`의 router는 질문과 tool descriptor만 받으며 bearer token 또는 provider credential을 받지 않는다. +- `ai_web_agent_console.oci_genai_sdk`는 `.env`에서 `OCI_AUTH_TYPE`, `OCI_CONFIG_FILE`, `OCI_GENAI_COMPARTMENT_ID`, `OCI_PROFILE`만 읽는다. - Oracle audit/business evidence 조회는 `/home/opc/kbmcp/.env`에서 `ORACLE_DB_USER`, `ORACLE_DB_PASSWORD`, `ORACLE_DSN`, `ORACLE_WALLET_PASSWORD`, `ORACLE_WALLET_DIR`를 읽는다. - wallet 기본 fallback은 `/home/opc/wallet/kbaipoc`이다. - 저장소에는 실제 `.env`, wallet, SQLite 대화 DB, 실제 VPD bearer token 원문을 포함하지 않는다. @@ -97,7 +98,7 @@ SQLite 대화 이력 저장 ## 2. 범위 (Scope) - **포함**: - - `apps/poc4/mcp_discovery_ui.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비 + - `ai-web-agent-console/app.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비 - `config/mcp_servers.json` 및 sample의 KB MCP 선언 정비 - KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화 - VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완 @@ -300,8 +301,8 @@ Authorization: Bearer 배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다. - 원격 실제 위치: `/home/opc/poc_4/poc4_active_source_20260714.tar.gz` -- 사용자 제시 경로 `/home/opc/poc_4/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다. -- 저장소 위치: `poc4_active_source_20260714/` +- 사용자 제시 경로 `/home/opc/poc_4/ai-web-agent-console/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다. +- 저장소 위치: `ai-web-agent-console/` - 포함 파일: Streamlit UI, MCP router, OCI GenAI client, 모델 profile, token preset sample, 기동/status script, requirements - 보안 확인: 실제 `.env`, 실제 VPD token 원문, 대화 SQLite DB는 포함하지 않았다. `vpd_token_presets.json`에는 placeholder만 있다. - DB 참조 경로: VPD 개발본 배포 서버에서는 `/home/opc/kbmcp/.env`의 접속 정보를 사용하고, wallet directory는 `/home/opc/wallet/kbaipoc`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다. @@ -380,7 +381,7 @@ ENABLED_COUNT = 4 SELECT_POLICY_COUNT = 4 ``` -현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `sql/adb/42_agent_ords_fga_execution_audit.sql`은 `CB_VPD_EXEC_AUDIT_` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다. +현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `database/adb/42_agent_ords_fga_execution_audit.sql`은 `CB_VPD_EXEC_AUDIT_` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다. ### 15.4 감사 이벤트 저장 위치 @@ -437,7 +438,7 @@ DBA_FGA_AUDIT_TRAIL event_count_7d = 0 ### 15.6 소스 구현과 화면 연결 -PoC4 스냅샷의 `apps/poc4/mcp_discovery_ui.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다. +PoC4 스냅샷의 `ai-web-agent-console/app.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다. | 함수 | 조회 대상 | 역할 | |---|---|---| diff --git a/docs/design/701-hmm-ai-agent-modularization/README.md b/docs/design/701-hmm-ai-agent-modularization/README.md index 4bafd67..9726cf9 100644 --- a/docs/design/701-hmm-ai-agent-modularization/README.md +++ b/docs/design/701-hmm-ai-agent-modularization/README.md @@ -2,7 +2,9 @@ ## 프로젝트 개요 -`poc4_active_source_20260714`는 레거시 스냅샷 경로이며, 정식 서비스명은 HMM AI 업무 에이전트다. HMM MCP를 통해 HR 데이터, 표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다. +정식 서비스명은 HMM AI 업무 에이전트이며 현재 소스 경계는 `ai-web-agent-console/`이다. +과거 `poc4_active_source_20260714` 스냅샷 경로는 #742에서 제거했다. HMM MCP를 통해 HR 데이터, +표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다. ## 목표 @@ -10,7 +12,7 @@ ## 현재 문제 -- `apps/poc4/mcp_discovery_ui.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다. +- `ai-web-agent-console/app.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다. - MCP 설정과 인증 토큰 규칙을 수정할 때 화면 코드까지 함께 읽어야 한다. - MCP 프로토콜 처리의 단위 검증 지점이 없다. @@ -18,14 +20,15 @@ | 모듈 | 책임 | Streamlit 의존 | | --- | --- | --- | -| `src/poc4/runtime_config.py` | `.env`, MCP 서버 JSON, VPD preset 로드와 검증 | 없음 | -| `src/poc4/mcp_client.py` | endpoint 검증, JSON-RPC, 세션 fallback, tool discovery/call | 없음 | -| `src/poc4/chat_store.py` | SQLite 대화 이력 CRUD | 없음 | -| `apps/poc4/mcp_discovery_ui.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 있음 | +| `ai-web-agent-console/ai_web_agent_console/profile.py` | 제품 프로필과 환경 override 로드 | 없음 | +| `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py` | MCP 도구 discovery 결과의 route와 arguments 구성 | 없음 | +| `ai-web-agent-console/ai_web_agent_console/auth_gateway.py` | 로그인·쿠키·세션 경계 | 없음 | +| `ai-web-agent-console/ai_web_agent_console/audit.py` | 감사·증적 조회 경계 | 없음 | +| `ai-web-agent-console/app.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 있음 | ### 재사용 UI Shell과 제품 프로필 -공통 화면 shell은 `src/agent_console/`에서 제공하고, 특정 고객·PoC의 표현은 +공통 화면 shell은 `ai-web-agent-console/ai_web_agent_console/`에서 제공하고, 특정 고객·PoC의 표현은 `config/app_profile.json`에 둔다. 다른 프로젝트는 앱 코드를 복사·수정하지 않고 profile JSON을 교체할 수 있다. 실제 배포에서는 `AGENT_CONSOLE_NAME`, `AGENT_CONSOLE_HEADER_DESCRIPTION`, `AGENT_CONSOLE_PRIMARY_COLOR` 등 `AGENT_CONSOLE_*` 환경변수가 JSON 기본값보다 우선한다. @@ -48,7 +51,7 @@ 2. 변경은 Redmine 이슈에 설계·검증 결과와 Git commit SHA를 함께 기록한다. 3. Git commit message에는 Redmine 번호를 `refs #<번호>:` 형식으로 포함한다. 4. 환경별 값과 비밀값은 profile JSON에 넣지 않고 `.env` 또는 secret store에만 둔다. -5. UI CSS는 `src/agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다. +5. UI CSS는 `ai-web-agent-console/ai_web_agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다. 도메인 로직의 공개 오류는 `PublicMcpError`로 통일한다. UI는 이 오류를 사람이 이해할 수 있는 메시지로 표시하되 토큰과 HTTP 원문을 출력하지 않는다. diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index 0623966..7c53b84 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -60,7 +60,7 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS - `/structured-data`와 `/schema-metadata`의 데이터 원본명, Oracle owner, 안내 문구, 최대 조회 건수, 허용 테이블 목록은 Java·HTML에 하드코딩하지 않는다. -- 기본 HMM 정의는 `src/main/resources/config/structured-data-catalog.json`에 둔다. 배포 환경에서는 +- 기본 HMM 정의는 `vpd-backoffice/src/main/resources/config/structured-data-catalog.json`에 둔다. 배포 환경에서는 `BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/.../structured-data-catalog.json`으로 외부 JSON을 지정할 수 있어 다른 회사 PoC에서 애플리케이션 코드를 수정하지 않고 재사용할 수 있다. - 각 테이블 정의는 `key`, `tableName`, `businessName`, `description`을 기본으로 하고 필요하면 diff --git a/docs/design/707-hmm-leave-team-vpd/README.md b/docs/design/707-hmm-leave-team-vpd/README.md index 330611c..fc78203 100644 --- a/docs/design/707-hmm-leave-team-vpd/README.md +++ b/docs/design/707-hmm-leave-team-vpd/README.md @@ -73,7 +73,7 @@ E1001에는 `HMM_HR_MANAGER`, E1002~E1007에는 `HMM_HR_VIEWER`를 부여한다. ## 배포 순서 1. 애플리케이션 테스트와 SQL 구문 검사를 수행한다. -2. 멱등 SQL `sql/adb/72_hmm_leave_team_vpd.sql`을 `ADMIN`으로 실행한다. +2. 멱등 SQL `database/adb/72_hmm_leave_team_vpd.sql`을 `ADMIN`으로 실행한다. 3. VPD 정책·컴파일 상태·백오피스 화면을 확인한다. 4. 백오피스와 같은 SHA-256 저장 규칙으로 E1001과 E1002 임시 검증 토큰을 만든다. 5. 비면제 검증 사용자에서 토큰별 조회 결과를 확인하고 검증 토큰과 사용자를 제거한다. diff --git a/docs/design/723-hmm-configurable-backoffice/README.md b/docs/design/723-hmm-configurable-backoffice/README.md index 4f061b0..be3d389 100644 --- a/docs/design/723-hmm-configurable-backoffice/README.md +++ b/docs/design/723-hmm-configurable-backoffice/README.md @@ -182,7 +182,7 @@ HMM 운영 MCP는 현재 세 개의 Agent Tool을 사용하므로 Select AI 접 BACKOFFICE_SECURITY_SQL_SCRIPTS ``` -번들된 `sql/adb` 경로 안의 파일만 허용하며 요청값을 resource path로 사용하지 않는다. +번들된 `database/adb` 경로 안의 파일만 허용하며 요청값을 resource path로 사용하지 않는다. HMM 운영에서는 `72_hmm_leave_team_vpd.sql` 등 HMM 관련 SQL만 노출한다. ## 5. 코드 구조 diff --git a/docs/design/729-rds-carrier-performance-demo/README.md b/docs/design/729-rds-carrier-performance-demo/README.md index 897c6a2..c29c77c 100644 --- a/docs/design/729-rds-carrier-performance-demo/README.md +++ b/docs/design/729-rds-carrier-performance-demo/README.md @@ -110,8 +110,8 @@ CHECK 제약으로 보장한다. 월별 조회와 위험 선사 조회에 각각 ## 7. 적재 순서 1. RDS TLS와 현재 계정의 CREATE 권한을 확인한다. -2. `sql/source/postgres_hmm_carrier_performance.sql`을 실행한다. -3. `sql/source/postgres_hmm_carrier_performance_verify.sql`을 실행한다. +2. `database/source/postgres_hmm_carrier_performance.sql`을 실행한다. +3. `database/source/postgres_hmm_carrier_performance_verify.sql`을 실행한다. 4. 재실행 후에도 동일한 선사 8개와 실적 144행인지 확인한다. 5. 후속 작업에서 ADB credential과 PostgreSQL database link를 만든다. diff --git a/docs/design/742-application-oriented-repository-layout/README.md b/docs/design/742-application-oriented-repository-layout/README.md new file mode 100644 index 0000000..de90082 --- /dev/null +++ b/docs/design/742-application-oriented-repository-layout/README.md @@ -0,0 +1,112 @@ +# #742 애플리케이션 단위 저장소 구조 개편 + +## 문제 + +현재 저장소 루트의 `pom.xml`, `src/main`, `src/test`는 실제로 하나의 Spring Boot +백오피스지만 제품 폴더 없이 저장소 전체 애플리케이션처럼 보인다. Streamlit 앱은 +`poc4_active_source_20260714`라는 날짜 스냅샷 폴더에 있고 내부에서도 `poc3`, `poc4`, +`agent_console`, 단독 Python 모듈이 서로 참조한다. + +이 구조에서는 다음 내용을 파일 경로만 보고 알 수 없다. + +- 어떤 파일이 실제 배포 애플리케이션인지 +- Spring Boot와 Streamlit의 빌드·테스트 경계가 어디인지 +- `poc3`, `poc4`가 제품명인지 과거 실험 번호인지 +- DB 스크립트와 특정 애플리케이션의 관계가 무엇인지 + +## 목표 구조 + +```text +vpd-permission-poc/ +├── vpd-backoffice/ Spring Boot VPD·권한 관리 백오피스 +│ ├── pom.xml +│ ├── src/main/ +│ └── src/test/ +├── ai-web-agent-console/ Streamlit AI 업무 에이전트 +│ ├── app.py +│ ├── ai_web_agent_console/ 단일 Python 패키지 +│ ├── config/ +│ ├── tests/ +│ └── requirements.txt +├── dds-backoffice/ 별도 DDS 백오피스 +├── database/ +│ ├── adb/ +│ └── source/ +├── deploy/ 제품별 배포 설정 +├── docs/ 설계·운영·검증 문서 +├── scripts/ 저장소 공통 자동화 +└── README.md +``` + +## 이동 매핑 + +| 기존 | 변경 | 처리 | +|---|---|---| +| `pom.xml` | `vpd-backoffice/pom.xml` | Maven 프로젝트 경계 명시 | +| `src/main` | `vpd-backoffice/src/main` | Spring 애플리케이션 소스 | +| `src/test` | `vpd-backoffice/src/test` | Spring 단위·통합 테스트 | +| `sql/adb` | `database/adb` | Oracle/ADB 스크립트 | +| `sql/source` | `database/source` | 외부 원천 DB 스크립트 | +| `poc4_active_source_20260714` | `ai-web-agent-console` | 날짜·PoC 번호 제거 | +| `apps/poc4/mcp_discovery_ui.py` | `ai-web-agent-console/app.py` | 명확한 Streamlit 진입점 | +| `src/agent_console/*` | `ai-web-agent-console/ai_web_agent_console/*` | 단일 제품 패키지 | +| `src/poc3/model_registry.py` | `ai-web-agent-console/ai_web_agent_console/model_registry.py` | 실험 번호 제거 | +| `src/poc3/questions.py` | `ai-web-agent-console/ai_web_agent_console/questions.py` | 실험 번호 제거 | +| `src/poc4/scenarios.py` | `ai-web-agent-console/ai_web_agent_console/scenarios.py` | 실험 번호 제거 | +| `src/poc4/query_contracts.py` | `ai-web-agent-console/ai_web_agent_console/query_contracts.py` | 실험 번호 제거 | +| `src/mcp_*.py`, `src/oci_genai_sdk.py` | `ai-web-agent-console/ai_web_agent_console/` | 앱 전용 공용 모듈 통합 | +| `config/poc3_model_profiles.json` | `ai-web-agent-console/config/model_profiles.json` | 실험 번호 제거 | + +## 경로 규칙 + +1. 저장소 루트에는 애플리케이션 프레임워크의 `src`나 `pom.xml`을 두지 않는다. +2. 각 애플리케이션은 자신의 소스, 의존성 선언, 테스트 진입점을 가진다. +3. DB 스크립트는 `database`를 Git 원본으로 사용하고 Spring JAR는 해당 경로를 + 리소스로 포함한다. +4. 배포 설정은 `deploy/`에서 새 소스 경로를 참조한다. +5. 과거 경로를 유지하는 심볼릭 링크나 복제본은 만들지 않는다. 잘못된 경로가 다시 + 사용되면 테스트가 실패하도록 한다. +6. 운영 중인 환경변수와 SQLite 테이블명은 데이터 호환을 위해 이번 변경에서 유지한다. + 폴더·패키지·실행 경로에서만 PoC 명칭을 제거한다. + +## Maven 변경 + +`vpd-backoffice/pom.xml`의 SQL 리소스 경로는 `../database/adb`를 사용한다. 루트 자동화는 +`mvn -f vpd-backoffice/pom.xml`을 사용하며 앱 디렉토리 안에서는 기존처럼 `mvn test`가 +동작해야 한다. + +Java 테스트에서 파일을 직접 여는 경로는 앱 디렉토리를 기준으로 유지한다. 따라서 +`vpd-backoffice`에서 실행한 테스트와 루트에서 `-f`로 실행한 테스트를 모두 확인한다. + +## Python 변경 + +`ai-web-agent-console/app.py`는 자신의 부모 폴더를 애플리케이션 루트로 사용한다. +모든 import는 `ai_web_agent_console.` 형식으로 통일한다. 테스트도 같은 공개 +패키지 경로만 사용한다. + +`model_profiles.json` 안의 `default_for_poc3` 속성은 `default_for_console`로 바꾸고 +로더·검증 코드도 함께 변경한다. 배포된 비밀정보 파일과 대화 DB는 Git 이동 대상이 +아니다. + +## 배포 변경 + +- Spring 배포 빌드는 `vpd-backoffice/pom.xml`과 `vpd-backoffice/target`을 사용한다. +- Streamlit 실행은 `streamlit run app.py`를 사용한다. +- 인증 게이트웨이는 `python -m ai_web_agent_console.auth_gateway`를 사용한다. +- 기존 운영 디렉토리 `/opt/hmm-poc4`는 무중단 전환을 위해 물리 경로로 유지할 수 있지만, + 서비스의 Git 소스 경로와 Python 모듈명은 새 구조를 사용한다. + +## 검증 + +1. `git status --short`에서 기존 #741 변경이 새 경로에 남아 있는지 확인한다. +2. `mvn -f vpd-backoffice/pom.xml test`를 실행한다. +3. Python 전체 소스에 `compileall`을 실행한다. +4. `unittest discover -s ai-web-agent-console/tests`를 실행한다. +5. `rg`로 운영 코드의 `src.poc3`, `src.poc4`, `src.agent_console`, + `poc4_active_source_20260714`, `apps/poc4` 참조가 0건인지 확인한다. +6. 배포 스크립트의 dry-run 또는 정적 경로 검증을 수행한다. + +## 롤백 + +모든 이동은 한 Git 커밋으로 추적한다. 문제가 생기면 해당 커밋을 revert하여 이전 경로와 +import를 함께 복구한다. DB 스키마와 운영 데이터는 이 구조 변경에서 수정하지 않는다. diff --git a/docs/notes/2026-06-08-dds-qa-session.md b/docs/notes/2026-06-08-dds-qa-session.md index 34490d4..6ef6dc8 100644 --- a/docs/notes/2026-06-08-dds-qa-session.md +++ b/docs/notes/2026-06-08-dds-qa-session.md @@ -98,7 +98,7 @@ STEP 8 (선택) ALTER TABLE ... SET USE DATA GRANTS ONLY ENABLED -- MAC 모드 STEP 9 감사 (dba_data_grants 등) + 역순 정리 ``` -전체 스크립트는 세션 본문 참조. 이 POC 의 `sql/adb/13_dds_variant.sql` + `15_dds_cleanup.sql` 가 동일 패턴. +전체 스크립트는 세션 본문 참조. 이 POC 의 `database/adb/13_dds_variant.sql` + `15_dds_cleanup.sql` 가 동일 패턴. --- @@ -325,8 +325,8 @@ CREATE DATA GRANT admin.dg_scoped ### 이 POC 에서 보면 -- `sql/adb/06_policy.sql` + `permission` 테이블 = "앱/운영자가 행으로 권한 관리" → **VPD 답안** -- `sql/adb/13_dds_variant.sql` = "DBA 가 DDL 로 4 종 역할 정의" → **DDS 답안** +- `database/adb/06_policy.sql` + `permission` 테이블 = "앱/운영자가 행으로 권한 관리" → **VPD 답안** +- `database/adb/13_dds_variant.sql` = "DBA 가 DDL 로 4 종 역할 정의" → **DDS 답안** - 같은 결과를 두 다른 모델로 표현한 비교 데모 **권한 매핑 테이블 중심으로 가겠다면 VPD 그대로 두는 게 정답.** diff --git a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md b/docs/notes/hmm-brand-refresh.md similarity index 92% rename from poc4_active_source_20260714/HMM_BRAND_REFRESH.md rename to docs/notes/hmm-brand-refresh.md index d98f22a..c945ae5 100644 --- a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md +++ b/docs/notes/hmm-brand-refresh.md @@ -6,7 +6,8 @@ - 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다. - 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다. - 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다. -- 공통 화면 CSS와 로그인/헤더 renderer는 `src/agent_console/presentation.py`에서 관리한다. +- 공통 화면 CSS와 로그인/헤더 renderer는 + `ai-web-agent-console/ai_web_agent_console/presentation.py`에서 관리한다. HMM의 제품명·문구·색상은 `config/app_profile.json`에만 둔다. # HMM MCP runtime wiring diff --git a/docs/reference/ords-vpd-sql-trace.md b/docs/reference/ords-vpd-sql-trace.md index c78f478..f994562 100644 --- a/docs/reference/ords-vpd-sql-trace.md +++ b/docs/reference/ords-vpd-sql-trace.md @@ -12,10 +12,10 @@ 기존 Handler가 trace 필드를 반환하지 않으면 백오피스가 같은 DB 연결에서 `set_user_by_bearer` → `CB_AGENT_DOC_VPD_FILTER` → `clear_user` 순서로 기본 VPD predicate를 조회한다. 이 보조 조회가 실패해도 권한 결과 자체는 실패 처리하지 않는다. -기존 설치에 trace 권한을 추가하려면 ADMIN으로 `sql/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다. +기존 설치에 trace 권한을 추가하려면 ADMIN으로 `database/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다. 기본으로 표시되는 SQL은 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다. 실행 DB가 `V$SQL`과 `DBMS_XPLAN` 조회를 허용하면 `/probe`는 최근 2분 내 같은 보호 객체의 SQL_ID, DB가 기록한 원문 SQL, cursor 누적 통계, Predicate Information도 함께 표시한다. `V$SQL` 원문은 VPD 주입 전 SQL이고, 실제 적용 조건은 `DBMS_XPLAN`의 Predicate Information에서 확인한다. 같은 객체에 동시 요청이 많은 경우에는 최근 cursor 매칭이므로 요청 ID 기반 상관 추적이 필요하다. -Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다. +Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../database/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다. diff --git a/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md b/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md index f44bbe7..62009bb 100644 --- a/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md +++ b/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md @@ -54,7 +54,7 @@ EXTERNAL_CLAUSE_NAME=개인용애니카다이렉트자동차보험 변경 스크립트: -- `sql/adb/65_kb_select_ai_vpd_query_api.sql` +- `database/adb/65_kb_select_ai_vpd_query_api.sql` ## RAG 근거 검색 확인 결과 diff --git a/docs/reports/2026-07-10-select-ai-profile-performance-report.md b/docs/reports/2026-07-10-select-ai-profile-performance-report.md index ee3beb3..3856e52 100644 --- a/docs/reports/2026-07-10-select-ai-profile-performance-report.md +++ b/docs/reports/2026-07-10-select-ai-profile-performance-report.md @@ -202,7 +202,7 @@ c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_P 대상 스크립트: ```text -sql/adb/65_kb_select_ai_vpd_query_api.sql +database/adb/65_kb_select_ai_vpd_query_api.sql ``` ### ORDS 응답 표시 @@ -216,7 +216,7 @@ profile=KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1 대상 스크립트: ```text -sql/adb/66_kb_select_ai_vpd_query_ords.sql +database/adb/66_kb_select_ai_vpd_query_ords.sql ``` ### MCP 응답 표시 @@ -226,8 +226,8 @@ MCP tool 응답 payload의 profile 표시와 화면 설명을 새 프로파일 대상 소스: ```text -src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java -src/main/resources/templates/mcp-sse.html +vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +vpd-backoffice/src/main/resources/templates/mcp-sse.html ``` ## 최종 속도 측정 diff --git a/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md b/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md index bba825a..d9573b3 100644 --- a/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md +++ b/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md @@ -8,7 +8,7 @@ ## 적용 결과 -`sql/adb/72_hmm_leave_team_vpd.sql`을 적용해 다음 구성을 활성화했다. +`database/adb/72_hmm_leave_team_vpd.sql`을 적용해 다음 구성을 활성화했다. | 구성 | 결과 | |---|---| diff --git a/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md b/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md index b499390..11d6312 100644 --- a/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md +++ b/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md @@ -32,7 +32,7 @@ ## 이번에 제거한 결함 1. 감사로그가 KB PoC의 `POC_2` FGA 메타데이터를 조회하던 경로를 제거했다. -2. 감사 원천을 `ADMIN.HMM_ACCESS_AUDIT`으로 교체하고 렌더러를 `src/agent_console/audit.py`로 +2. 감사 원천을 `ADMIN.HMM_ACCESS_AUDIT`으로 교체하고 렌더러를 `ai-web-agent-console/ai_web_agent_console/audit.py`로 분리했다. 3. Python Thin driver가 암호화 `ewallet.pem`의 암호를 대화형으로 기다리던 연결을 ADB TLS 1521 서버 인증 풀로 교체했다. diff --git a/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md b/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md index 9cea05f..65fb9fc 100644 --- a/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md +++ b/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md @@ -25,9 +25,9 @@ SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@ SQLcl 안에서 실행: ```sql -@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter -@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql +@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql ``` 기대 결과: @@ -64,7 +64,7 @@ SQLCL_JAVA_HOME=/path/to/jdk \ 가장 단순한 롤백은 직전 Git revision의 `26_agent_ords_security_dynamic_vpd_filter.sql`을 다시 적용하는 것이다. ```bash -git show HEAD~1:sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql +git show HEAD~1:database/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@TNS" ``` diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md index 83bffc1..86e423e 100644 --- a/docs/runbooks/hmm-mcp-token-configuration.md +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -151,7 +151,7 @@ BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<운영 secret> ``` -DB 구성 스크립트는 `sql/adb/73_hmm_mcp_vpd_runtime.sql`이다. 이 스크립트는 승인된 HMM +DB 구성 스크립트는 `database/adb/73_hmm_mcp_vpd_runtime.sql`이다. 이 스크립트는 승인된 HMM HR 객체에 대한 개별 `SELECT`와 handler package 실행 권한만 부여하며 `EXEMPT ACCESS POLICY`는 부여하지 않는다. diff --git a/poc4_active_source_20260714/.env.sample b/poc4_active_source_20260714/.env.sample deleted file mode 100644 index c25caae..0000000 --- a/poc4_active_source_20260714/.env.sample +++ /dev/null @@ -1,131 +0,0 @@ -# PoC_4 runtime environment template -# -# 사용법: -# cp .env.sample .env -# chmod 600 .env -# 편집 후 scripts/poc4/start_*_nohup.sh 로 기동합니다. - -# 제품별 화면 값: 기본값은 config/app_profile.json, 아래 값이 있으면 환경변수가 우선합니다. -# AGENT_CONSOLE_NAME=HMM AI 업무 에이전트 -# AGENT_CONSOLE_SHORT_NAME=HMM -# AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트 -# AGENT_CONSOLE_PAGE_ICON=⛴️ -# AGENT_CONSOLE_HEADER_TITLE=AI 업무 에이전트 -# AGENT_CONSOLE_HEADER_DESCRIPTION=사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다. -# AGENT_CONSOLE_LOGIN_KICKER=HMM SHIPPING & LOGISTICS DEMO -# AGENT_CONSOLE_LOGIN_TITLE=HMM AI 업무 에이전트 -# AGENT_CONSOLE_LOGIN_DESCRIPTION=사용자 인증 후 해운·물류 AI 질의와 보안 관리 기능을 이용할 수 있습니다. -# AGENT_CONSOLE_LOGIN_FOOTER=인증된 DEMO 사용자만 접근할 수 있습니다. -# AGENT_CONSOLE_PRIMARY_COLOR=#003b70 -# AGENT_CONSOLE_TEXT_COLOR=#172b3a -# AGENT_CONSOLE_MUTED_COLOR=#667785 -# AGENT_CONSOLE_BORDER_COLOR=#dfe7ed -# -# 실제 token, password, OCID, wallet 경로는 이 샘플에 기록하지 않습니다. -# 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다. - -# ----------------------------------------------------------------------------- -# MCP runtime (현재 PoC_4가 공유하는 PoC_3 호환 환경변수 계약) -# ----------------------------------------------------------------------------- -POC3_MCP_PROVIDER=custom_python -POC3_MCP_BASE_URL=http://127.0.0.1:8500 -POC3_MCP_AUTH_MODE=bearer -POC3_MCP_TOKEN= -# HMM MCP gateway token. 실제 값은 배포 서버 .env에만 둡니다. -HMM_MCP_BEARER_TOKEN= -POC3_MCP_TIMEOUT_SECONDS=30 -POC3_MCP_FALLBACK_TO_MOCK=false -POC3_MCP_LIVE_SMOKE=false - -# MCP server registry -# 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다. -# 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다. -POC4_MCP_SERVERS_FILE=config/mcp_servers.json -POC4_MCP_DEFAULT_SERVER_ID=hmm_hr_mcp - -# MCP discovery UI conversation history store -# 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3 -POC4_CHAT_DB_PATH=data/poc4_mcp_chat.sqlite3 - -# ----------------------------------------------------------------------------- -# Local trace / optional LangSmith metadata -# ----------------------------------------------------------------------------- -POC3_TRACE_MODE=local -POC3_TRACE_UI_ENABLED=true -LANGSMITH_TRACING=false -LANGSMITH_API_KEY= -LANGSMITH_PROJECT=kb-aidp-poc4 - -# ----------------------------------------------------------------------------- -# PoC_4 UI ports -# 현재 VM 기본 포트만 사용합니다. 공식 포트 전환은 .env만으로 허용되지 않으며 -# POC4_ALLOW_OFFICIAL_PORTS=1을 기동 명령의 환경에 별도로 지정해야 합니다. -# ----------------------------------------------------------------------------- -POC4_LANGGRAPH_UI_PORT=8612 -POC4_AGENT_TEAM_UI_PORT=8613 -POC4_LANGGRAPH_TC_UI_PORT=8622 -POC4_AGENT_TEAM_TC_UI_PORT=8623 - -# ----------------------------------------------------------------------------- -# OCI Generative AI -# compartment ID는 배포 환경의 값을 입력합니다. API key/private key 원문은 넣지 -# 않고 OCI config file 또는 instance/resource principal을 사용합니다. -# ----------------------------------------------------------------------------- -OCI_AUTH_TYPE=config_file -OCI_CONFIG_FILE=~/.oci/config -OCI_PROFILE=DEFAULT -OCI_GENAI_COMPARTMENT_ID= - -# 모델 route는 config/poc3_model_profiles.json의 검증된 기본값을 사용합니다. -# 배포 환경에서 route를 바꿔야 할 때만 아래 항목의 주석을 해제합니다. -# POC3_LLM_GPT55_OCI_MODEL_ID=openai.gpt-5.5 -# POC3_LLM_GPT55_OCI_REGION=us-chicago-1 -# POC3_LLM_GPT55_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -# POC3_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini -# POC3_LLM_GPT54_MINI_OCI_REGION=us-chicago-1 -# POC3_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -# POC3_LLM_GROK43_MODEL_ID=xai.grok-4.3 -# POC3_LLM_GROK43_REGION=us-chicago-1 -# POC3_LLM_GROK43_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -# 화면 LLM 모델 선택의 기본값입니다. -# 선택 모델은 후속 질문 정리, 실행 방식 판단, MCP tool 라우팅, -# Agent planning, 최종 답변 합성에 사용됩니다. -# 실패 시 애플리케이션 기본 fallback인 gpt54_mini_oci로 재시도합니다. -POC4_COMPLEX_REASONING_MODEL_PROFILE=grok43 - -# ----------------------------------------------------------------------------- -# Audit / security evidence DB connection -# ----------------------------------------------------------------------------- -# VPD 개발본 배포 서버 기준: -# - DB 접속 secret은 /home/opc/kbmcp/.env 에 둡니다. -# - DB wallet은 /home/opc/wallet/kbaipoc 를 사용합니다. -# 이 샘플에는 DB password, wallet password, token 원문을 넣지 않습니다. -POC4_AUDIT_DB_ENV_FILE=/home/opc/kbmcp/.env -ORACLE_WALLET_DIR=/home/opc/wallet/kbaipoc - -# ----------------------------------------------------------------------------- -# Direct Oracle DB administration tools (목표 계약) -# Streamlit/MCP-only 배포에서는 모두 비워 둡니다. 현재 launcher는 .env 전체를 UI -# process에 export하므로 역할별 설정 loader가 구현되기 전에는 이 파일에 DB secret을 -# 채우지 않습니다. DB migration/audit 전용 process environment에서만 주입합니다. -# ----------------------------------------------------------------------------- -POC4_DB_ADMIN_ENABLED=false -POC4_DB_USERNAME= -POC4_DB_PASSWORD= -POC4_DB_DSN= -POC4_DB_WALLET_DIR= -POC4_DB_WALLET_PASSWORD= -POC4_DB_EXPECTED_SCHEMA= -POC4_DB_CLIENT_LIB_DIR= -POC4_DB_MODE=thick -POC4_DB_CONNECT_TIMEOUT_SECONDS=60 -POC4_DB_CALL_TIMEOUT_MS=120000 - -# 신규 tenancy 이관 preflight에서만 사용하며 값 자체는 출력하지 않습니다. -POC4_EXPECTED_TENANCY_ID= - -# 다음 값은 복제된 .env가 보안/프로세스 제어를 바꾸지 못하도록 launcher 호출자만 -# 지정할 수 있습니다. 이 파일에 활성 값으로 추가하지 않습니다. -# POC4_BIND_ADDRESS=0.0.0.0 -# POC4_ALLOW_OFFICIAL_PORTS=1 -# POC4_MANAGED_FOREGROUND=1 diff --git a/poc4_active_source_20260714/SOURCE_README.md b/poc4_active_source_20260714/SOURCE_README.md deleted file mode 100644 index e86baa2..0000000 --- a/poc4_active_source_20260714/SOURCE_README.md +++ /dev/null @@ -1,65 +0,0 @@ -# HMM AI 업무 에이전트 소스 - -생성일: 2026-07-14 GMT - -이 폴더는 HMM AI 업무 에이전트 실행 소스다. `poc4`라는 디렉터리·환경변수 접두어는 -기존 배포 호환성을 위한 레거시 경로이며, 서비스의 정식 이름은 **HMM AI 업무 에이전트**다. - -## Entrypoint - -```bash -streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622 -``` - -## Runtime - -- Python 3.11 이상을 사용합니다. -- 이 개발 서버의 기본 `python3`가 3.6 계열이면 문법 검증이 실패합니다. -- 배포 서버는 `/opt/hmm-poc4/.venv`와 Python 3.11을 사용합니다. -- OCI Generative AI SDK 인증은 배포 서버의 `/home/opc/.oci/config` `DEFAULT` profile을 사용합니다. - private key와 compartment OCID는 Git에 저장하지 않고 서버의 `~/.oci`와 `.env`에서만 관리합니다. - -## Deployment DB reference - -- VPD 개발본 배포 서버의 DB 접속 정보는 `/home/opc/kbmcp/.env`를 사용합니다. -- DB wallet directory는 `/home/opc/wallet/kbaipoc`를 사용합니다. -- 이 저장소에는 wallet 파일, DB password, wallet password, 실제 VPD token 원문을 넣지 않습니다. - -## Included - -- `apps/poc4/mcp_discovery_ui.py` -- `src/agent_console/presentation.py` -- `src/agent_console/profile.py` -- `src/mcp_tool_router.py` -- `src/oci_genai_sdk.py` -- `src/poc3/model_registry.py` -- `src/poc3/questions.py` -- `config/mcp_servers.json` -- `config/poc3_model_profiles.json` -- `config/vpd_token_presets.json` -- `config/hmm_demo_scenarios.json` — 화면에 표시할 데모 질문. 코드 수정 없이 이 파일만 변경한다. -- `config/app_profile.json` — 제품명, 문구, 아이콘, 색상을 지정하는 제품 profile -- `.env.sample` -- `requirements.txt` -- `requirements-langgraph.txt` -- `scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh` -- `scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh` - -## Security note - -- 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요. -- `config/vpd_token_presets.json`은 파일명 호환성을 유지한 HMM **데모 사용자** 목록입니다. - 사용자별 `mcp_token_env`는 배포 환경의 `HMM_MCP_BEARER_TOKEN`만 참조하며, token 원문은 JSON·Git에 저장하지 않습니다. -- 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다. -- MCP 호출 상세의 JSON/code 영역은 공통 presentation CSS에서 밝은 배경과 어두운 글자를 함께 지정합니다. - -## 구성 원칙 - -- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, HMM 데모 사용자 preset, 데모 질문 JSON -- `src/poc4/`: Streamlit과 분리 가능한 화면 보조 모듈 및 도메인 로직 -- `apps/poc4/`: 레거시 호환 entrypoint. 화면 조립과 사용자 상호작용만 담당하도록 점진적으로 축소 -- `tests/`: 설정 파일과 순수 Python 모듈의 회귀 검증 - -다른 프로젝트에 적용할 때는 scenario/MCP JSON만 교체하고, 제품명·설명·색상은 -`AGENT_CONSOLE_*` 환경변수로 지정합니다. 환경변수가 없을 때만 `config/app_profile.json`의 -중립 기본값을 사용합니다. diff --git a/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz b/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz deleted file mode 100644 index 9df851ef6cebef7d017144bec76208d46c0c709a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74102 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 z 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 diff --git a/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh b/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh deleted file mode 100755 index 58b03e4..0000000 --- a/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -source "/home/opc/poc_4/scripts/poc4/runtime_ui_lib.sh" -poc4_start_service "8622_langgraph_tc_ui" "/home/opc/poc_4/scripts/poc4/run_8622_langgraph_tc_ui.sh" "apps/poc4/langgraph_tc_ui.py" diff --git a/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh b/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh deleted file mode 100755 index 565daad..0000000 --- a/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -source "/home/opc/poc_4/scripts/poc4/runtime_ui_lib.sh" -poc4_status_service "8622_langgraph_tc_ui" "apps/poc4/langgraph_tc_ui.py" diff --git a/poc4_active_source_20260714/src/poc4/__init__.py b/poc4_active_source_20260714/src/poc4/__init__.py deleted file mode 100644 index ae16b2b..0000000 --- a/poc4_active_source_20260714/src/poc4/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""PoC4 UI support modules.""" diff --git a/run.sh b/run.sh index f7a3d2b..ea11a84 100755 --- a/run.sh +++ b/run.sh @@ -131,48 +131,48 @@ do_source() { PGPASSWORD="$PG_PASSWORD" psql \ -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ -v ON_ERROR_STOP=1 \ - -f "$ROOT/sql/source/postgres_setup.sql" + -f "$ROOT/database/source/postgres_setup.sql" ok "Postgres source 준비 완료" log "-- MySQL ($MY_HOST:$MY_PORT/$MY_DB) --" mysql \ -h "$MY_HOST" -P "$MY_PORT" -u "$MY_USER" "-p${MY_PASSWORD}" \ - "$MY_DB" < "$ROOT/sql/source/mysql_setup.sql" + "$MY_DB" < "$ROOT/database/source/mysql_setup.sql" ok "MySQL source 준비 완료" } do_adb() { log "=== adb: ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users ===" - run_sqlplus_file "$ROOT/sql/adb/00_cleanup.sql" - run_sqlplus_file "$ROOT/sql/adb/01_dblinks.sql" - run_sqlplus_file "$ROOT/sql/adb/02_perm_tables.sql" - run_sqlplus_file "$ROOT/sql/adb/03_seed.sql" - run_sqlplus_file "$ROOT/sql/adb/04_secure_ctx.sql" - run_sqlplus_file "$ROOT/sql/adb/05_views.sql" - run_sqlplus_file "$ROOT/sql/adb/06_policy.sql" - run_sqlplus_file "$ROOT/sql/adb/06a_redaction.sql" - run_sqlplus_file "$ROOT/sql/adb/07_end_users.sql" + run_sqlplus_file "$ROOT/database/adb/00_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/01_dblinks.sql" + run_sqlplus_file "$ROOT/database/adb/02_perm_tables.sql" + run_sqlplus_file "$ROOT/database/adb/03_seed.sql" + run_sqlplus_file "$ROOT/database/adb/04_secure_ctx.sql" + run_sqlplus_file "$ROOT/database/adb/05_views.sql" + run_sqlplus_file "$ROOT/database/adb/06_policy.sql" + run_sqlplus_file "$ROOT/database/adb/06a_redaction.sql" + run_sqlplus_file "$ROOT/database/adb/07_end_users.sql" ok "ADB setup 완료" } do_tests() { log "=== tests: 4-user access matrix 검증 ===" - run_sqlplus_as vpduser_my "$VPDUSER_MY_PASSWORD" "$ROOT/sql/adb/08_tests_user_my.sql" - run_sqlplus_as vpduser_pg "$VPDUSER_PG_PASSWORD" "$ROOT/sql/adb/09_tests_user_pg.sql" - run_sqlplus_as vpduser_both "$VPDUSER_BOTH_PASSWORD" "$ROOT/sql/adb/10_tests_user_both.sql" - run_sqlplus_as vpduser_none "$VPDUSER_NONE_PASSWORD" "$ROOT/sql/adb/11_tests_user_none.sql" + run_sqlplus_as vpduser_my "$VPDUSER_MY_PASSWORD" "$ROOT/database/adb/08_tests_user_my.sql" + run_sqlplus_as vpduser_pg "$VPDUSER_PG_PASSWORD" "$ROOT/database/adb/09_tests_user_pg.sql" + run_sqlplus_as vpduser_both "$VPDUSER_BOTH_PASSWORD" "$ROOT/database/adb/10_tests_user_both.sql" + run_sqlplus_as vpduser_none "$VPDUSER_NONE_PASSWORD" "$ROOT/database/adb/11_tests_user_none.sql" ok "4명 (MY / PG / BOTH / NONE) 테스트 실행 완료" } do_audit() { log "=== audit: admin 으로 정책 / 뷰 / 매트릭스 점검 ===" - run_sqlplus_file "$ROOT/sql/adb/12_tests_admin_audit.sql" + run_sqlplus_file "$ROOT/database/adb/12_tests_admin_audit.sql" ok "audit 완료" } do_backoffice_support() { log "=== backoffice-support: Spring Boot 백오피스 보조 객체 생성 ===" - run_sqlplus_file "$ROOT/sql/adb/25_agent_ords_security_backoffice_support.sql" + run_sqlplus_file "$ROOT/database/adb/25_agent_ords_security_backoffice_support.sql" ok "backoffice-support 완료" } @@ -191,7 +191,7 @@ do_backoffice_env_check() { do_teardown() { log "=== teardown: ADB 측 객체 + dblink/credential 정리 ===" warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)" - run_sqlplus_file "$ROOT/sql/adb/00_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/00_cleanup.sql" ok "teardown 완료" } @@ -209,24 +209,24 @@ do_dds_prereq() { do_dds_setup() { log "=== dds-setup: 13_dds_variant.sql (END USER + DATA ROLE + DATA GRANT) ===" # 멱등성: 부분 적용 상태를 먼저 청소한 뒤 재생성 - run_sqlplus_file "$ROOT/sql/adb/15_dds_cleanup.sql" - run_sqlplus_file "$ROOT/sql/adb/13_dds_variant.sql" + run_sqlplus_file "$ROOT/database/adb/15_dds_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/13_dds_variant.sql" ok "DDS 셋업 완료" } do_dds_tests() { log "=== dds-tests: 4-user (ddsuser_*) 매트릭스 검증 ===" # END USER 이름이 lowercase 라 사용자명을 큰따옴표로 감싸서 전달. - run_sqlplus_as '"ddsuser_my"' "$DDSUSER_MY_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" - run_sqlplus_as '"ddsuser_pg"' "$DDSUSER_PG_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" - run_sqlplus_as '"ddsuser_both"' "$DDSUSER_BOTH_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" - run_sqlplus_as '"ddsuser_none"' "$DDSUSER_NONE_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_my"' "$DDSUSER_MY_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_pg"' "$DDSUSER_PG_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_both"' "$DDSUSER_BOTH_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_none"' "$DDSUSER_NONE_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" ok "DDS 4명 (MY / PG / BOTH / NONE) 테스트 실행 완료" } do_dds_teardown() { log "=== dds-teardown: DDS 객체만 정리 (VPD 데모는 보존) ===" - run_sqlplus_file "$ROOT/sql/adb/15_dds_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/15_dds_cleanup.sql" ok "DDS teardown 완료" } diff --git a/scripts/configure-backoffice-https-vm.sh b/scripts/configure-backoffice-https-vm.sh index 40a37bc..3eb7631 100755 --- a/scripts/configure-backoffice-https-vm.sh +++ b/scripts/configure-backoffice-https-vm.sh @@ -112,7 +112,7 @@ render_caddyfile() { gsub(/{{TLS_OPTIONS}}/, tls_options) print } - ' "$ROOT/deploy/caddy/Caddyfile.template" > "$output" + ' "$ROOT/deploy/vpd-backoffice/caddy/Caddyfile.template" > "$output" } build_ssh_args() { @@ -264,7 +264,7 @@ INSPECT log "Caddy candidate 업로드" ssh "${SSH_ARGS[@]}" "$HOST" "mkdir -p \"$remote_stage\"" - cp "$ROOT/deploy/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh" + cp "$ROOT/deploy/vpd-backoffice/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh" tar -C "$tmp_dir" -czf - Caddyfile install-caddy-config.sh \ | ssh "${SSH_ARGS[@]}" "$HOST" "tar -C \"$remote_stage\" -xzf - && chmod 700 \"$remote_stage/install-caddy-config.sh\"" diff --git a/scripts/deploy-backoffice-vm.sh b/scripts/deploy-backoffice-vm.sh index 5fc2451..393c242 100755 --- a/scripts/deploy-backoffice-vm.sh +++ b/scripts/deploy-backoffice-vm.sh @@ -32,7 +32,7 @@ Production transport defaults are always applied on the VM: HTTPS required, Secure/HttpOnly/SameSite session cookie. The script uploads: - - target/*.jar as app.jar + - vpd-backoffice/target/*.jar as app.jar - .env rewritten for the VM wallet path - TNS_ADMIN wallet directory - start.sh / stop.sh / status.sh @@ -127,11 +127,11 @@ ssh "${SSH_ARGS[@]}" "$HOST" 'printf "connected %s %s\n" "$(hostname)" "$(whoami if [[ "$SKIP_BUILD" != "Y" ]]; then log "jar 빌드" - mvn -DskipTests package + mvn -f "$ROOT/vpd-backoffice/pom.xml" -DskipTests package fi -JAR_PATH="$(find "$ROOT/target" -maxdepth 1 -type f -name '*.jar' ! -name '*sources.jar' ! -name '*javadoc.jar' | sort | tail -n 1)" -[[ -n "$JAR_PATH" && -f "$JAR_PATH" ]] || die "target jar를 찾을 수 없습니다. mvn package를 먼저 실행하세요." +JAR_PATH="$(find "$ROOT/vpd-backoffice/target" -maxdepth 1 -type f -name '*.jar' ! -name '*sources.jar' ! -name '*javadoc.jar' | sort | tail -n 1)" +[[ -n "$JAR_PATH" && -f "$JAR_PATH" ]] || die "vpd-backoffice target jar를 찾을 수 없습니다. 배포 빌드를 먼저 실행하세요." TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-deploy.XXXXXX")" cleanup() { diff --git a/scripts/deploy-dds-backoffice-local.sh b/scripts/deploy-dds-backoffice-local.sh index b5bc50b..99bf0b0 100755 --- a/scripts/deploy-dds-backoffice-local.sh +++ b/scripts/deploy-dds-backoffice-local.sh @@ -13,10 +13,10 @@ if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then fi cd "$ROOT" -mvn -DskipTests package +mvn -f "$ROOT/vpd-backoffice/pom.xml" -DskipTests package mvn install:install-file \ - -Dfile="$ROOT/target/vpd-permission-backoffice-0.1.0-SNAPSHOT.jar.original" \ - -DpomFile="$ROOT/pom.xml" + -Dfile="$ROOT/vpd-backoffice/target/vpd-permission-backoffice-0.1.0-SNAPSHOT.jar.original" \ + -DpomFile="$ROOT/vpd-backoffice/pom.xml" cd "$ROOT/dds-backoffice" mvn -DskipTests package diff --git a/scripts/provision-dds-token-search.sh b/scripts/provision-dds-token-search.sh index 74c9f29..322902f 100644 --- a/scripts/provision-dds-token-search.sh +++ b/scripts/provision-dds-token-search.sh @@ -15,8 +15,8 @@ connect="${DDS_ADMIN_USERNAME}/${DDS_ADMIN_PASSWORD}@${DDS_ADMIN_DB_URL}" sqlplus -s "$connect" < None: + for relative in ( + "vpd-backoffice/pom.xml", + "vpd-backoffice/src/main", + "ai-web-agent-console/app.py", + "ai-web-agent-console/ai_web_agent_console", + "dds-backoffice/pom.xml", + "database/adb", + "deploy/vpd-backoffice", + "deploy/ai-web-agent-console", + ): + self.assertTrue((ROOT / relative).exists(), relative) + + def test_ambiguous_legacy_roots_are_absent(self) -> None: + for relative in ( + "pom.xml", + "src", + "sql", + "poc4_active_source_20260714", + ): + self.assertFalse((ROOT / relative).exists(), relative) + + def test_console_has_one_named_python_package(self) -> None: + console = ROOT / "ai-web-agent-console" + self.assertFalse((console / "src").exists()) + self.assertFalse((console / "apps").exists()) + self.assertTrue((console / "ai_web_agent_console" / "__init__.py").is_file()) + + source = "\n".join( + path.read_text(encoding="utf-8") + for path in (console / "ai_web_agent_console").glob("*.py") + ) + source += (console / "app.py").read_text(encoding="utf-8") + for forbidden in ( + "from src.", + "src.poc3", + "src.poc4", + "src.agent_console", + "apps/poc4", + ): + self.assertNotIn(forbidden, source) + + def test_vpd_backoffice_packages_database_sql(self) -> None: + pom = (ROOT / "vpd-backoffice" / "pom.xml").read_text(encoding="utf-8") + self.assertIn("../database/adb", pom) + self.assertNotIn("sql/adb", pom) + + +if __name__ == "__main__": + unittest.main() diff --git a/pom.xml b/vpd-backoffice/pom.xml similarity index 95% rename from pom.xml rename to vpd-backoffice/pom.xml index eefd731..d49518b 100644 --- a/pom.xml +++ b/vpd-backoffice/pom.xml @@ -92,11 +92,11 @@ src/main/resources - - sql/adb - sql/adb + ../database/adb + database/adb **/*.sql diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java similarity index 97% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index e5edcd2..3b31783 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -82,7 +82,8 @@ public record BackofficeProperties( String profile, String runtimeDbUrl, String runtimeDbUsername, - String runtimeDbPassword + String runtimeDbPassword, + String queryContractFile ) { public boolean configured() { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java similarity index 99% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index a3b6603..173901d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -709,8 +709,8 @@ public class BackofficeSchemaService { return """ -- sqlcl에서 실행할 HMM VPD runtime 준비 순서 -- 1. ADMIN으로 접속 - @sql/adb/25_agent_ords_security_backoffice_support.sql - @sql/adb/72_hmm_leave_team_vpd.sql + @database/adb/25_agent_ords_security_backoffice_support.sql + @database/adb/72_hmm_leave_team_vpd.sql -- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한 CONNECT %s/@ diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java similarity index 96% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java index 1550c72..61dabdb 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -17,7 +17,7 @@ import org.springframework.stereotype.Service; /** * Read-only catalogue of security deployment SQL bundled from the Git-tracked - * sql/adb directory. Script ids are an application whitelist: request input + * database/adb directory. Script ids are an application whitelist: request input * never becomes a filesystem or classpath path. */ @Service @@ -64,7 +64,7 @@ public class SecuritySqlScriptService { } private String readSource(String fileName) { - ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName); + ClassPathResource resource = new ClassPathResource("database/adb/" + fileName); try (InputStream input = resource.getInputStream()) { return new String(input.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java similarity index 88% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index ef5c239..453bb4c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -7,6 +7,9 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.CallableStatement; import java.sql.Connection; import java.sql.DriverManager; @@ -63,7 +66,13 @@ public class SelectAiService { } String generatedSql = generate( - selectAi, vpdAwarePrompt(normalizedPrompt, principal)); + selectAi, + vpdAwarePrompt( + normalizedPrompt, + principal, + loadQueryContract(selectAi.queryContractFile()) + ) + ); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly( selectAi, normalizedSql, bearerToken.trim(), principal); @@ -94,8 +103,12 @@ public class SelectAiService { return normalized; } - private String vpdAwarePrompt(String prompt, HmmMcpPrincipal principal) { - return """ + private String vpdAwarePrompt( + String prompt, + HmmMcpPrincipal principal, + String queryContract + ) { + String basePrompt = """ Oracle SQL 생성 규칙: - 프로필에 승인된 HMM HR 객체만 사용하세요. - 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요. @@ -104,6 +117,37 @@ public class SelectAiService { 사용자 질문: %s """.formatted(principal.employeeCode(), prompt); + if (queryContract == null || queryContract.isBlank()) { + return basePrompt; + } + return basePrompt + """ + + 배포별 질의 계약(JSON): + 아래 계약 중 사용자 질문에 해당하는 항목만 적용하세요. 필수 필드, 계산, + 시간 기준, 누락 레코드 의미와 금지 fallback을 그대로 지키세요. + """ + queryContract; + } + + private String loadQueryContract(String configuredPath) { + if (configuredPath == null || configuredPath.isBlank()) { + return ""; + } + try { + Path path = Path.of(configuredPath.trim()).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path)) { + throw new AppException("Select AI 질의 계약 파일을 찾을 수 없습니다: " + path); + } + long size = Files.size(path); + if (size < 2 || size > 128_000) { + throw new AppException("Select AI 질의 계약 파일 크기가 허용 범위를 벗어났습니다."); + } + return Files.readString(path, StandardCharsets.UTF_8); + } catch (AppException exception) { + throw exception; + } catch (Exception exception) { + throw new AppException("Select AI 질의 계약 파일을 읽지 못했습니다: " + + safeMessage(exception)); + } } private String generate( diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java diff --git a/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml similarity index 96% rename from src/main/resources/application.yml rename to vpd-backoffice/src/main/resources/application.yml index 16f5d20..6824731 100644 --- a/src/main/resources/application.yml +++ b/vpd-backoffice/src/main/resources/application.yml @@ -78,6 +78,8 @@ backoffice: runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:} runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:} runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:} + # Customer/project query semantics are external JSON, never Java constants. + query-contract-file: ${BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE:} catalog: owner: ${BACKOFFICE_CATALOG_OWNER:} objects: ${BACKOFFICE_CATALOG_OBJECTS:} diff --git a/src/main/resources/mapper/AuditMapper.xml b/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml similarity index 100% rename from src/main/resources/mapper/AuditMapper.xml rename to vpd-backoffice/src/main/resources/mapper/AuditMapper.xml diff --git a/src/main/resources/mapper/BearerTokenMapper.xml b/vpd-backoffice/src/main/resources/mapper/BearerTokenMapper.xml similarity index 100% rename from src/main/resources/mapper/BearerTokenMapper.xml rename to vpd-backoffice/src/main/resources/mapper/BearerTokenMapper.xml diff --git a/src/main/resources/mapper/GroupMapper.xml b/vpd-backoffice/src/main/resources/mapper/GroupMapper.xml similarity index 100% rename from src/main/resources/mapper/GroupMapper.xml rename to vpd-backoffice/src/main/resources/mapper/GroupMapper.xml diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml similarity index 100% rename from src/main/resources/mapper/MaskingRuleMapper.xml rename to vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml diff --git a/src/main/resources/mapper/OperationStatusMapper.xml b/vpd-backoffice/src/main/resources/mapper/OperationStatusMapper.xml similarity index 100% rename from src/main/resources/mapper/OperationStatusMapper.xml rename to vpd-backoffice/src/main/resources/mapper/OperationStatusMapper.xml diff --git a/src/main/resources/mapper/OrdsMetadataMapper.xml b/vpd-backoffice/src/main/resources/mapper/OrdsMetadataMapper.xml similarity index 100% rename from src/main/resources/mapper/OrdsMetadataMapper.xml rename to vpd-backoffice/src/main/resources/mapper/OrdsMetadataMapper.xml diff --git a/src/main/resources/mapper/PermissionMapper.xml b/vpd-backoffice/src/main/resources/mapper/PermissionMapper.xml similarity index 100% rename from src/main/resources/mapper/PermissionMapper.xml rename to vpd-backoffice/src/main/resources/mapper/PermissionMapper.xml diff --git a/src/main/resources/mapper/ProtectedObjectMapper.xml b/vpd-backoffice/src/main/resources/mapper/ProtectedObjectMapper.xml similarity index 100% rename from src/main/resources/mapper/ProtectedObjectMapper.xml rename to vpd-backoffice/src/main/resources/mapper/ProtectedObjectMapper.xml diff --git a/src/main/resources/mapper/SettingMapper.xml b/vpd-backoffice/src/main/resources/mapper/SettingMapper.xml similarity index 100% rename from src/main/resources/mapper/SettingMapper.xml rename to vpd-backoffice/src/main/resources/mapper/SettingMapper.xml diff --git a/src/main/resources/mapper/StakeholderMapper.xml b/vpd-backoffice/src/main/resources/mapper/StakeholderMapper.xml similarity index 100% rename from src/main/resources/mapper/StakeholderMapper.xml rename to vpd-backoffice/src/main/resources/mapper/StakeholderMapper.xml diff --git a/src/main/resources/mapper/UserMapper.xml b/vpd-backoffice/src/main/resources/mapper/UserMapper.xml similarity index 100% rename from src/main/resources/mapper/UserMapper.xml rename to vpd-backoffice/src/main/resources/mapper/UserMapper.xml diff --git a/src/main/resources/mapper/VpdPolicyMapper.xml b/vpd-backoffice/src/main/resources/mapper/VpdPolicyMapper.xml similarity index 100% rename from src/main/resources/mapper/VpdPolicyMapper.xml rename to vpd-backoffice/src/main/resources/mapper/VpdPolicyMapper.xml diff --git a/src/main/resources/static/css/app.css b/vpd-backoffice/src/main/resources/static/css/app.css similarity index 100% rename from src/main/resources/static/css/app.css rename to vpd-backoffice/src/main/resources/static/css/app.css diff --git a/src/main/resources/static/js/app.js b/vpd-backoffice/src/main/resources/static/js/app.js similarity index 100% rename from src/main/resources/static/js/app.js rename to vpd-backoffice/src/main/resources/static/js/app.js diff --git a/src/main/resources/templates/dashboard.html b/vpd-backoffice/src/main/resources/templates/dashboard.html similarity index 100% rename from src/main/resources/templates/dashboard.html rename to vpd-backoffice/src/main/resources/templates/dashboard.html diff --git a/src/main/resources/templates/effective-matrix.html b/vpd-backoffice/src/main/resources/templates/effective-matrix.html similarity index 100% rename from src/main/resources/templates/effective-matrix.html rename to vpd-backoffice/src/main/resources/templates/effective-matrix.html diff --git a/src/main/resources/templates/error.html b/vpd-backoffice/src/main/resources/templates/error.html similarity index 100% rename from src/main/resources/templates/error.html rename to vpd-backoffice/src/main/resources/templates/error.html diff --git a/src/main/resources/templates/fragments/layout.html b/vpd-backoffice/src/main/resources/templates/fragments/layout.html similarity index 100% rename from src/main/resources/templates/fragments/layout.html rename to vpd-backoffice/src/main/resources/templates/fragments/layout.html diff --git a/src/main/resources/templates/fragments/mcp-chatbot-result.html b/vpd-backoffice/src/main/resources/templates/fragments/mcp-chatbot-result.html similarity index 100% rename from src/main/resources/templates/fragments/mcp-chatbot-result.html rename to vpd-backoffice/src/main/resources/templates/fragments/mcp-chatbot-result.html diff --git a/src/main/resources/templates/fragments/mcp-client-demo-result.html b/vpd-backoffice/src/main/resources/templates/fragments/mcp-client-demo-result.html similarity index 100% rename from src/main/resources/templates/fragments/mcp-client-demo-result.html rename to vpd-backoffice/src/main/resources/templates/fragments/mcp-client-demo-result.html diff --git a/src/main/resources/templates/fragments/mcp-reasoning-result.html b/vpd-backoffice/src/main/resources/templates/fragments/mcp-reasoning-result.html similarity index 100% rename from src/main/resources/templates/fragments/mcp-reasoning-result.html rename to vpd-backoffice/src/main/resources/templates/fragments/mcp-reasoning-result.html diff --git a/src/main/resources/templates/fragments/ords-handler-source.html b/vpd-backoffice/src/main/resources/templates/fragments/ords-handler-source.html similarity index 100% rename from src/main/resources/templates/fragments/ords-handler-source.html rename to vpd-backoffice/src/main/resources/templates/fragments/ords-handler-source.html diff --git a/src/main/resources/templates/fragments/probe-result.html b/vpd-backoffice/src/main/resources/templates/fragments/probe-result.html similarity index 100% rename from src/main/resources/templates/fragments/probe-result.html rename to vpd-backoffice/src/main/resources/templates/fragments/probe-result.html diff --git a/src/main/resources/templates/fragments/security-sql-script-explanation.html b/vpd-backoffice/src/main/resources/templates/fragments/security-sql-script-explanation.html similarity index 100% rename from src/main/resources/templates/fragments/security-sql-script-explanation.html rename to vpd-backoffice/src/main/resources/templates/fragments/security-sql-script-explanation.html diff --git a/src/main/resources/templates/fragments/vector-search-result.html b/vpd-backoffice/src/main/resources/templates/fragments/vector-search-result.html similarity index 100% rename from src/main/resources/templates/fragments/vector-search-result.html rename to vpd-backoffice/src/main/resources/templates/fragments/vector-search-result.html diff --git a/src/main/resources/templates/fragments/vpd-function-source.html b/vpd-backoffice/src/main/resources/templates/fragments/vpd-function-source.html similarity index 100% rename from src/main/resources/templates/fragments/vpd-function-source.html rename to vpd-backoffice/src/main/resources/templates/fragments/vpd-function-source.html diff --git a/src/main/resources/templates/fragments/vpd-object-filter-detail.html b/vpd-backoffice/src/main/resources/templates/fragments/vpd-object-filter-detail.html similarity index 100% rename from src/main/resources/templates/fragments/vpd-object-filter-detail.html rename to vpd-backoffice/src/main/resources/templates/fragments/vpd-object-filter-detail.html diff --git a/src/main/resources/templates/fragments/vpd-policy-detail.html b/vpd-backoffice/src/main/resources/templates/fragments/vpd-policy-detail.html similarity index 100% rename from src/main/resources/templates/fragments/vpd-policy-detail.html rename to vpd-backoffice/src/main/resources/templates/fragments/vpd-policy-detail.html diff --git a/src/main/resources/templates/fragments/vpd-policy-explanation.html b/vpd-backoffice/src/main/resources/templates/fragments/vpd-policy-explanation.html similarity index 100% rename from src/main/resources/templates/fragments/vpd-policy-explanation.html rename to vpd-backoffice/src/main/resources/templates/fragments/vpd-policy-explanation.html diff --git a/src/main/resources/templates/groups.html b/vpd-backoffice/src/main/resources/templates/groups.html similarity index 100% rename from src/main/resources/templates/groups.html rename to vpd-backoffice/src/main/resources/templates/groups.html diff --git a/src/main/resources/templates/login.html b/vpd-backoffice/src/main/resources/templates/login.html similarity index 100% rename from src/main/resources/templates/login.html rename to vpd-backoffice/src/main/resources/templates/login.html diff --git a/src/main/resources/templates/masking-rules.html b/vpd-backoffice/src/main/resources/templates/masking-rules.html similarity index 100% rename from src/main/resources/templates/masking-rules.html rename to vpd-backoffice/src/main/resources/templates/masking-rules.html diff --git a/src/main/resources/templates/mcp-chatbot.html b/vpd-backoffice/src/main/resources/templates/mcp-chatbot.html similarity index 100% rename from src/main/resources/templates/mcp-chatbot.html rename to vpd-backoffice/src/main/resources/templates/mcp-chatbot.html diff --git a/src/main/resources/templates/mcp-client-demo.html b/vpd-backoffice/src/main/resources/templates/mcp-client-demo.html similarity index 100% rename from src/main/resources/templates/mcp-client-demo.html rename to vpd-backoffice/src/main/resources/templates/mcp-client-demo.html diff --git a/src/main/resources/templates/mcp-reasoning.html b/vpd-backoffice/src/main/resources/templates/mcp-reasoning.html similarity index 100% rename from src/main/resources/templates/mcp-reasoning.html rename to vpd-backoffice/src/main/resources/templates/mcp-reasoning.html diff --git a/src/main/resources/templates/mcp-sse.html b/vpd-backoffice/src/main/resources/templates/mcp-sse.html similarity index 100% rename from src/main/resources/templates/mcp-sse.html rename to vpd-backoffice/src/main/resources/templates/mcp-sse.html diff --git a/src/main/resources/templates/objects.html b/vpd-backoffice/src/main/resources/templates/objects.html similarity index 100% rename from src/main/resources/templates/objects.html rename to vpd-backoffice/src/main/resources/templates/objects.html diff --git a/src/main/resources/templates/operation-status.html b/vpd-backoffice/src/main/resources/templates/operation-status.html similarity index 100% rename from src/main/resources/templates/operation-status.html rename to vpd-backoffice/src/main/resources/templates/operation-status.html diff --git a/src/main/resources/templates/ords-handlers.html b/vpd-backoffice/src/main/resources/templates/ords-handlers.html similarity index 100% rename from src/main/resources/templates/ords-handlers.html rename to vpd-backoffice/src/main/resources/templates/ords-handlers.html diff --git a/src/main/resources/templates/permissions.html b/vpd-backoffice/src/main/resources/templates/permissions.html similarity index 100% rename from src/main/resources/templates/permissions.html rename to vpd-backoffice/src/main/resources/templates/permissions.html diff --git a/src/main/resources/templates/probe.html b/vpd-backoffice/src/main/resources/templates/probe.html similarity index 100% rename from src/main/resources/templates/probe.html rename to vpd-backoffice/src/main/resources/templates/probe.html diff --git a/src/main/resources/templates/roles.html b/vpd-backoffice/src/main/resources/templates/roles.html similarity index 100% rename from src/main/resources/templates/roles.html rename to vpd-backoffice/src/main/resources/templates/roles.html diff --git a/src/main/resources/templates/schema-metadata.html b/vpd-backoffice/src/main/resources/templates/schema-metadata.html similarity index 100% rename from src/main/resources/templates/schema-metadata.html rename to vpd-backoffice/src/main/resources/templates/schema-metadata.html diff --git a/src/main/resources/templates/security-sql-scripts.html b/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html similarity index 92% rename from src/main/resources/templates/security-sql-scripts.html rename to vpd-backoffice/src/main/resources/templates/security-sql-scripts.html index 9524494..01157fa 100644 --- a/src/main/resources/templates/security-sql-scripts.html +++ b/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html @@ -51,7 +51,7 @@ 62_kb_aso_masking_backoffice_metadata.sql -

Git source: sql/adb/62_kb_aso_masking_backoffice_metadata.sql. 실제 DB 배포본은 행 접근 필터 구조 및 DB 배포 이력과 함께 확인하세요.

+

Git source: database/adb/62_kb_aso_masking_backoffice_metadata.sql. 실제 DB 배포본은 행 접근 필터 구조 및 DB 배포 이력과 함께 확인하세요.

diff --git a/src/main/resources/templates/settings-database.html b/vpd-backoffice/src/main/resources/templates/settings-database.html similarity index 99% rename from src/main/resources/templates/settings-database.html rename to vpd-backoffice/src/main/resources/templates/settings-database.html index c36ab57..3ea34fe 100644 --- a/src/main/resources/templates/settings-database.html +++ b/vpd-backoffice/src/main/resources/templates/settings-database.html @@ -58,7 +58,7 @@

실행 전 SQL 확인

앱에서 실행하는 지원 DDL
CREATE TABLE ...
-
sqlcl로 실행할 VPD/ORDS runtime 순서
@sql/adb/...
+
sqlcl로 실행할 VPD/ORDS runtime 순서
@database/adb/...
diff --git a/src/main/resources/templates/settings.html b/vpd-backoffice/src/main/resources/templates/settings.html similarity index 100% rename from src/main/resources/templates/settings.html rename to vpd-backoffice/src/main/resources/templates/settings.html diff --git a/src/main/resources/templates/structured-data.html b/vpd-backoffice/src/main/resources/templates/structured-data.html similarity index 100% rename from src/main/resources/templates/structured-data.html rename to vpd-backoffice/src/main/resources/templates/structured-data.html diff --git a/src/main/resources/templates/tokens.html b/vpd-backoffice/src/main/resources/templates/tokens.html similarity index 100% rename from src/main/resources/templates/tokens.html rename to vpd-backoffice/src/main/resources/templates/tokens.html diff --git a/src/main/resources/templates/user-masking-rules.html b/vpd-backoffice/src/main/resources/templates/user-masking-rules.html similarity index 100% rename from src/main/resources/templates/user-masking-rules.html rename to vpd-backoffice/src/main/resources/templates/user-masking-rules.html diff --git a/src/main/resources/templates/users.html b/vpd-backoffice/src/main/resources/templates/users.html similarity index 100% rename from src/main/resources/templates/users.html rename to vpd-backoffice/src/main/resources/templates/users.html diff --git a/src/main/resources/templates/vector-knowledge.html b/vpd-backoffice/src/main/resources/templates/vector-knowledge.html similarity index 100% rename from src/main/resources/templates/vector-knowledge.html rename to vpd-backoffice/src/main/resources/templates/vector-knowledge.html diff --git a/src/main/resources/templates/vpd-filter-policies.html b/vpd-backoffice/src/main/resources/templates/vpd-filter-policies.html similarity index 100% rename from src/main/resources/templates/vpd-filter-policies.html rename to vpd-backoffice/src/main/resources/templates/vpd-filter-policies.html diff --git a/src/main/resources/templates/vpd-filter-runtime.html b/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html similarity index 100% rename from src/main/resources/templates/vpd-filter-runtime.html rename to vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html diff --git a/src/main/resources/templates/vpd-policies.html b/vpd-backoffice/src/main/resources/templates/vpd-policies.html similarity index 100% rename from src/main/resources/templates/vpd-policies.html rename to vpd-backoffice/src/main/resources/templates/vpd-policies.html diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplateTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplateTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplateTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplateTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumnTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumnTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumnTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumnTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifierTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifierTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifierTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifierTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunnerTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixTemplateRenderTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixTemplateRenderTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixTemplateRenderTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixTemplateRenderTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java similarity index 96% rename from src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index 303be54..df876dd 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -296,7 +296,7 @@ class GuidedFlowTemplateTest { @Test void probeResultSeparatesDbExecutionEvidenceFromReconstructedSql() throws IOException { String result = template("fragments/probe-result.html"); - String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql")); + String vectorSql = Files.readString(Path.of("../database/adb/29_agent_ords_vector_search_ords.sql")); assertThat(result) .contains("DB 감사 실행 증적") @@ -317,7 +317,7 @@ class GuidedFlowTemplateTest { @Test void vectorDefaultRolesAreSeededAsWhitelistAllRules() throws IOException { - String setup = Files.readString(Path.of("sql/adb/32_dds_vector_tag_setup.sql")); + String setup = Files.readString(Path.of("../database/adb/32_dds_vector_tag_setup.sql")); assertThat(setup) .contains("ensure_vector_all_permission(10)") @@ -330,7 +330,7 @@ class GuidedFlowTemplateTest { @Test void defaultVerificationUsersUseHumanNamesInsteadOfRoleLikeAgentNames() throws IOException { - String seed = Files.readString(Path.of("sql/adb/17_agent_ords_security_local_vpd_setup.sql")); + String seed = Files.readString(Path.of("../database/adb/17_agent_ords_security_local_vpd_setup.sql")); String users = template("users.html"); String result = template("fragments/probe-result.html"); @@ -344,7 +344,7 @@ class GuidedFlowTemplateTest { @Test void vectorOrdsHandlerReadsBodyStreamOnlyOnce() throws IOException { - String sql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql")); + String sql = Files.readString(Path.of("../database/adb/29_agent_ords_vector_search_ords.sql")); assertThat(sql) .contains("v_body_text CLOB") @@ -358,8 +358,8 @@ class GuidedFlowTemplateTest { @Test void existingOrdsSetupCanReturnTheVpdPredicateTrace() throws IOException { - String setup = Files.readString(Path.of("sql/adb/22_agent_ords_security_ords_handler_setup.sql")); - String grant = Files.readString(Path.of("sql/adb/33_agent_ords_sql_trace_grant.sql")); + String setup = Files.readString(Path.of("../database/adb/22_agent_ords_security_ords_handler_setup.sql")); + String grant = Files.readString(Path.of("../database/adb/33_agent_ords_sql_trace_grant.sql")); assertThat(setup) .contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')") diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/PermissionTemplateRenderTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/PermissionTemplateRenderTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/web/PermissionTemplateRenderTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/PermissionTemplateRenderTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/ProbeResultTemplateRenderTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/ProbeResultTemplateRenderTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/web/ProbeResultTemplateRenderTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/ProbeResultTemplateRenderTest.java diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/vpd-backoffice/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker similarity index 100% rename from src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker rename to vpd-backoffice/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
AnnotationValue
사용자대상 컬럼기본 규칙원문 표시 상태상태
KB_VPD_ADMINPOC_2.KB_CUSTOMERS.RRN_MASKEDdemo-userOWNER.OBJECT_NAME.COLUMN_NAME 주민번호 기본 마스킹 · 주민등록번호 부분 마스킹 원문 표시 예외 Y
POC_2.KB_CONTRACTSKB_KB_CONTRACTS_ROW_POLICYOWNER.OBJECT_NAMEROW_ACCESS_POLICY SELECT 적용됨 ADMIN.CB_AGENT_DOC_VPD_FILTER