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 @@