From 53342e7bc373c7be2051b871f6d62e53e4bd9bf7 Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 19:14:37 +0900 Subject: [PATCH] refs #722: externalize backoffice customer configuration --- .env.example | 22 +++++- .../722-configurable-data-catalog/README.md | 78 +++++++++++++++++++ pom.xml | 9 +-- .../smilegate/70_tool_user.sql} | 0 .../smilegate/71_identity_administration.sql} | 0 .../vpdbackoffice/config/AppConfig.java | 9 ++- .../config/BackofficeProperties.java | 2 +- .../config/CatalogProperties.java | 7 ++ .../vpdbackoffice/config/DbPoolWarmup.java | 2 +- .../config/MaskingProperties.java | 8 ++ .../vpdbackoffice/config/McpProperties.java | 41 ++++++++++ .../config/ProductProperties.java | 10 +++ .../config/SecuritySqlScriptProperties.java | 8 ++ .../structured/StructuredDataTable.java | 4 + .../mapper/MaskingRuleMapper.java | 6 +- .../service/BackofficeSchemaService.java | 16 ++-- .../vpdbackoffice/service/DataCatalog.java | 10 +++ .../service/EnvironmentDataCatalog.java | 54 +++++++++++++ .../EnvironmentMaskingPolicyCatalog.java | 52 +++++++++++++ .../service/MaskingPolicyCatalog.java | 16 ++++ .../service/MaskingPolicySynchronizer.java | 61 +++++++-------- .../service/MaskingPolicyTarget.java | 5 ++ .../service/MaskingRuleService.java | 15 +++- .../vpdbackoffice/service/McpSseService.java | 52 +++++++++---- .../service/SchemaMetadataService.java | 23 +++--- .../service/SecuritySqlScriptService.java | 62 ++++++++++----- ...ectAiService.java => SelectAiService.java} | 12 +-- .../service/StructuredDataService.java | 42 +++------- .../web/DashboardController.java | 2 +- .../vpdbackoffice/web/ProductModelAdvice.java | 25 ++++++ .../web/SchemaMetadataController.java | 2 +- src/main/resources/application.yml | 22 +++++- .../resources/mapper/MaskingRuleMapper.xml | 28 ++++--- .../resources/templates/fragments/layout.html | 4 +- .../resources/templates/masking-rules.html | 10 +-- src/main/resources/templates/mcp-sse.html | 12 +-- .../resources/templates/operation-status.html | 4 +- .../templates/security-sql-scripts.html | 8 +- .../resources/templates/structured-data.html | 12 +-- src/main/resources/templates/tokens.html | 4 +- .../templates/user-masking-rules.html | 4 +- .../templates/vpd-filter-runtime.html | 4 +- .../MaskingPolicySynchronizerTest.java | 7 +- .../service/McpSseServiceTest.java | 22 ++++-- .../service/SchemaMetadataServiceTest.java | 4 +- .../service/SecuritySqlScriptServiceTest.java | 17 +++- .../service/StructuredDataServiceTest.java | 21 ++--- 47 files changed, 622 insertions(+), 216 deletions(-) create mode 100644 docs/design/722-configurable-data-catalog/README.md rename sql/adb/{70_sg_tool_user.sql => legacy/smilegate/70_tool_user.sql} (100%) rename sql/adb/{71_sg_identity_administration.sql => legacy/smilegate/71_identity_administration.sql} (100%) 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 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/MaskingPolicyCatalog.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java rename src/main/java/com/cloudhandson/vpdbackoffice/service/{SmilegateSelectAiService.java => SelectAiService.java} (94%) create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java diff --git a/.env.example b/.env.example index 9066fb9..ac1188d 100644 --- a/.env.example +++ b/.env.example @@ -51,12 +51,28 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_PASSWORD="" -# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다. +# Select AI는 프로파일 소유 스키마로 별도 접속합니다. # 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다. export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" -export BACKOFFICE_SELECT_AI_DB_USERNAME="SGMP_POC" +export BACKOFFICE_SELECT_AI_DB_USERNAME="" export BACKOFFICE_SELECT_AI_DB_PASSWORD="" -export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_OCI_GPT54MINI" +export BACKOFFICE_SELECT_AI_PROFILE="" + +# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다. +# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다. +export BACKOFFICE_CATALOG_OWNER="APP_OWNER" +export BACKOFFICE_CATALOG_OBJECTS='[{"key":"customers","tableName":"CUSTOMER","objectType":"TABLE","businessName":"고객","description":"고객 기본 정보"}]' +export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice" +export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice" +export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터" +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="업무 데이터에서 조회할 내용을 자연어로 입력합니다." +# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다. +export BACKOFFICE_MASKING_POLICIES='' +# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다. +export BACKOFFICE_SECURITY_SQL_SCRIPTS='' # --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- export BACKOFFICE_AI_ENABLED="false" diff --git a/docs/design/722-configurable-data-catalog/README.md b/docs/design/722-configurable-data-catalog/README.md new file mode 100644 index 0000000..832a000 --- /dev/null +++ b/docs/design/722-configurable-data-catalog/README.md @@ -0,0 +1,78 @@ +# 설계서: 환경변수 기반 공통 데이터 카탈로그 + +## 프로젝트 개요 + +이 백오피스는 Oracle Database의 권한, 메타데이터, Select AI와 정형 데이터 조회를 +운영하기 위한 공통 관리 화면이다. 현재 일부 화면은 특정 스키마와 업무 테이블 목록을 +코드에 고정하고 있어, 다른 프로젝트에 재사용하려면 Java와 MyBatis를 함께 수정해야 한다. + +## 목표 + +1. DB 접속은 기존 `BACKOFFICE_*_DB_*` 환경변수 체계를 유지한다. +2. 메타데이터와 정형 데이터 조회 대상은 `BACKOFFICE_CATALOG_OWNER`와 + `BACKOFFICE_CATALOG_OBJECTS`에서 선언한다. +3. 테이블과 뷰를 공통 `DataCatalogObject` 인터페이스로 표현한다. +4. 서비스와 MyBatis는 검증된 카탈로그 객체에서 전달받은 owner, object name, object type만 + 사용한다. HTTP 요청값을 SQL 식별자로 쓰지 않는다. +5. 카탈로그 환경변수가 비어 있거나 잘못되면 기동 시 실패한다. 다른 고객의 객체를 기본값으로 + 참조하지 않는다. + +## 설정 계약 + +```bash +export BACKOFFICE_CATALOG_OWNER="APP_OWNER" +export BACKOFFICE_CATALOG_OBJECTS='[ + {"key":"sales","tableName":"SALES_TXN","objectType":"TABLE", + "businessName":"판매 거래","description":"판매 거래 정보"}, + {"key":"daily-sales","tableName":"VW_DAILY_SALES","objectType":"VIEW", + "businessName":"일별 판매","description":"일별 판매 집계 뷰"} +]' +``` + +- `key`: 화면 URL과 선택값에 사용하는 영문 키. 소문자, 숫자, `-`만 허용한다. +- `tableName`: Oracle 단순 식별자. 대문자, 숫자, `_`, `$`, `#`만 허용한다. +- `objectType`: `TABLE` 또는 `VIEW`. +- `businessName`, `description`: 화면 표시용 텍스트. + +잘못된 JSON, 중복 key/name, 빈 목록, 허용되지 않은 식별자는 기동 시 명확히 실패한다. + +## 구조 + +```text +환경변수 + → CatalogProperties + → DataCatalog + → StructuredDataService / SchemaMetadataService + → MyBatis Mapper + → Oracle dictionary / 허용 객체 +``` + +`DataCatalog`은 허용 객체를 해석하는 단일 진입점이다. 미리보기 SQL은 객체 이름을 +카탈로그에서만 받아 조합하며, 목록 밖 이름은 SQL에 들어갈 수 없다. + +## 보안 SQL 번들 + +보안 SQL 화면은 `BACKOFFICE_SECURITY_SQL_SCRIPTS` JSON 배열에 선언한 번들만 표시한다. +각 항목은 `scriptId`, `category`, `fileName`, `title`, `description`을 가진다. +`fileName`은 패키지의 `sql/adb/` 하위 상대 경로만 허용하며, 요청값으로 경로를 만들지 않는다. +기존 고객 전용 SQL은 `sql/adb/legacy//`에 보존하고, 다른 환경에는 해당 목록을 +선언하지 않는다. + +## MyBatis 처리 + +- table/view comment와 column comment 조회는 `owner`, `objectName`을 바인드한다. +- annotation 조회는 Oracle dictionary 제약에 맞춰 `objectName`, `objectType`을 함께 + 바인드한다. +- 주석 DDL은 `COMMENT ON TABLE` 문법으로 테이블 또는 뷰에 적용한다. +- annotation DDL은 `TABLE`에만 허용한다. 뷰는 comment 편집만 제공한다. + +## 완료 기준 + +- 환경변수로 테이블과 뷰를 섞은 카탈로그를 선언할 수 있다. +- metadata와 preview가 선언된 owner/object만 조회한다. +- 뷰의 comment/column comment는 조회·수정 가능하고, annotation 편집은 차단된다. +- 설정 파싱과 허용 목록 검증을 자동 테스트한다. + +## 비범위 + +- Select AI profile 내부 object list를 자동으로 생성·변경하지 않는다. diff --git a/pom.xml b/pom.xml index 6153fbf..14b770c 100644 --- a/pom.xml +++ b/pom.xml @@ -92,15 +92,12 @@ src/main/resources - + sql/adb sql/adb - - 70_sg_tool_user.sql - 71_sg_identity_administration.sql - + **/*.sql diff --git a/sql/adb/70_sg_tool_user.sql b/sql/adb/legacy/smilegate/70_tool_user.sql similarity index 100% rename from sql/adb/70_sg_tool_user.sql rename to sql/adb/legacy/smilegate/70_tool_user.sql diff --git a/sql/adb/71_sg_identity_administration.sql b/sql/adb/legacy/smilegate/71_identity_administration.sql similarity index 100% rename from sql/adb/71_sg_identity_administration.sql rename to sql/adb/legacy/smilegate/71_identity_administration.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 804fcec..c980efc 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -74,7 +74,7 @@ public record BackofficeProperties( } } - /** Separate ADB connection because Select AI profiles are owned by SGMP_POC. */ + /** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */ public record SelectAi( String dbUrl, String dbUsername, 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..d2e546c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java @@ -0,0 +1,7 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "backoffice.catalog") +public record CatalogProperties(String owner, String objects) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java index 85e1faa..b11d1c4 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java @@ -51,6 +51,6 @@ public class DbPoolWarmup { groupService.findGroupRoles(); permissionService.findRoles(); permissionService.findPermissionViews(); - log.info("Smilegate identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000); + log.info("Identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000); } } 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..de1519f --- /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 is allowed to 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..f052933 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -0,0 +1,41 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Product-neutral labels and endpoint details for the MCP Select AI tool. */ +@ConfigurationProperties(prefix = "backoffice.mcp") +public record McpProperties( + String toolName, + String toolLabel, + String toolDescription, + String promptDescription +) { + + 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을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. " + + "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다."; + private static final String DEFAULT_PROMPT_DESCRIPTION = + "업무 데이터에서 조회할 내용을 자연어로 입력합니다."; + + 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..6dd6bdc --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@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; } + public String pageTitle() { return title == null || title.isBlank() ? displayName() : title; } + public String dataName() { return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel; } +} 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/structured/StructuredDataTable.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index a247fc5..b1f19b3 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.domain.structured; public record StructuredDataTable( String key, String tableName, + String objectType, String businessName, String description ) { + public StructuredDataTable(String key, String tableName, String businessName, String description) { + this(key, tableName, "TABLE", businessName, description); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index ca4edb8..2224664 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -5,6 +5,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.UserMaskingRule; +import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -28,7 +29,10 @@ public interface MaskingRuleMapper { List findColumnRules(); - List findPolicyStatuses(); + List findPolicyStatuses( + @Param("owner") String owner, + @Param("targets") List targets + ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index e5c7862..d3c6887 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -272,10 +272,16 @@ public class BackofficeSchemaService { private final JdbcTemplate jdbcTemplate; private final BackofficeProperties properties; + private final DataCatalog dataCatalog; - public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) { + public BackofficeSchemaService( + JdbcTemplate jdbcTemplate, + BackofficeProperties properties, + DataCatalog dataCatalog + ) { this.jdbcTemplate = jdbcTemplate; this.properties = properties; + this.dataCatalog = dataCatalog; } public SchemaPreflightView preflight() { @@ -705,7 +711,7 @@ public class BackofficeSchemaService { @sql/adb/17_agent_ords_security_local_vpd_setup.sql @sql/adb/25_agent_ords_security_backoffice_support.sql @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql - @sql/adb/71_sg_identity_administration.sql + -- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행 @sql/adb/21_agent_ords_security_ords_enable_schema.sql -- 2. ORDS parsing schema로 접속 @@ -717,9 +723,9 @@ public class BackofficeSchemaService { GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; GRANT SELECT ON . TO cb_ords; - -- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결 - -- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다. - """.formatted(owner.toLowerCase()); + -- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결 + -- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다. + """.formatted(owner.toLowerCase(), dataCatalog.owner()); } private void appendSql(StringBuilder builder, String sql) { 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..1c4cfd3 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java @@ -0,0 +1,10 @@ +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..990f5b0 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java @@ -0,0 +1,54 @@ +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.List; +import java.util.Locale; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +@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 mapper) { + owner = requireName(properties.owner()); + objects = parse(properties.objects(), mapper); + } + + @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 mapper) { + if (raw == null || raw.isBlank()) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요."); + } + try { + List values = mapper.readValue(raw, new TypeReference<>() {}); + if (values.isEmpty() || values.stream().map(StructuredDataTable::key).distinct().count() != values.size()) throw new IllegalArgumentException(); + values.forEach(this::validate); + return List.copyOf(values); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception); + } + } + private void validate(StructuredDataTable value) { + if (value == null || value.key() == null || !KEY.matcher(value.key()).matches() + || !NAME.matcher(value.tableName().toUpperCase(Locale.ROOT)).matches() + || !("TABLE".equalsIgnoreCase(value.objectType()) || "VIEW".equalsIgnoreCase(value.objectType()))) throw new IllegalArgumentException(); + } + private String requireName(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!NAME.matcher(normalized).matches()) throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요."); + return normalized; + } +} 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..11e3079 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java @@ -0,0 +1,52 @@ +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.List; +import java.util.Locale; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Loads the managed redaction policy allow-list from BACKOFFICE_MASKING_POLICIES. */ +@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() + || parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) { + throw new IllegalArgumentException(); + } + List normalized = parsed.stream() + .map(item -> new MaskingPolicyTarget(normalize(item.objectName()), normalize(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(); + } + return normalized; + } +} 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..e176b8b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java @@ -0,0 +1,16 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; +import java.util.Set; + +public interface MaskingPolicyCatalog { + List targets(); + + default Set objectNames() { + return targets().stream().map(MaskingPolicyTarget::objectName).collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + default boolean containsObject(String objectName) { + return objectName != null && objectNames().contains(objectName.trim().toUpperCase(java.util.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 02dd396..b8ef453 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; 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; @@ -24,37 +23,31 @@ import org.springframework.stereotype.Service; @Service public class MaskingPolicySynchronizer { - private static final String OWNER = "SGMP_POC"; 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 DataCatalog dataCatalog; + private final MaskingPolicyCatalog policyCatalog; - public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) { + public MaskingPolicySynchronizer( + JdbcTemplate jdbcTemplate, + MaskingRuleMapper mapper, + DataCatalog dataCatalog, + MaskingPolicyCatalog policyCatalog + ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - } - - private static Map managedPolicyMap() { - Map policies = new LinkedHashMap<>(); - policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT"); - policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT"); - policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT"); - policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT"); - return Collections.unmodifiableMap(policies); + this.dataCatalog = dataCatalog; + this.policyCatalog = policyCatalog; } public Set managedObjectNames() { - return MANAGED_POLICIES.keySet(); + return policyCatalog.objectNames(); } public boolean isManagedObject(String objectName) { - return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); - } - - static String managedPolicyName(String objectName) { - return MANAGED_POLICIES.get(objectName); + return policyCatalog.containsObject(objectName); } /** @@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer { public MaskingPolicySyncResult synchronize() { Map> desiredByObject = new LinkedHashMap<>(); for (ColumnMaskingRule rule : mapper.findColumnRules()) { - if (OWNER.equalsIgnoreCase(rule.owner()) + if (dataCatalog.owner().equalsIgnoreCase(rule.owner()) && rule.ruleEnabled() - && MANAGED_POLICIES.containsKey(rule.objectName())) { + && policyCatalog.containsObject(rule.objectName())) { desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); } } @@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer { int addedColumns = 0; int modifiedColumns = 0; int droppedColumns = 0; - for (Map.Entry policy : MANAGED_POLICIES.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()) { @@ -141,7 +134,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, dataCatalog.owner(), objectName, policyName); return statuses.isEmpty() ? null : statuses.getFirst(); } @@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer { SELECT column_name FROM redaction_columns WHERE object_owner = ? AND object_name = ? - """, String.class, OWNER, objectName).stream() + """, String.class, dataCatalog.owner(), objectName).stream() .map(this::requiredColumnName) .toList(); } @@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void enablePolicy(String objectName, String policyName) { @@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void dropColumn(String objectName, String policyName, String columnName) { @@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer { action => DBMS_REDACT.DROP_COLUMN, column_name => ? ); END; - """, OWNER, objectName, policyName, columnName); + """, dataCatalog.owner(), objectName, policyName, columnName); } private void addPolicy( @@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } return; } @@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(actionConstant, functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } } @@ -314,7 +307,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, 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..7da5387 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java @@ -0,0 +1,5 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** A validated object-to-redaction-policy mapping supplied by deployment configuration. */ +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 8ddef0b..bd5d064 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -28,19 +28,25 @@ public class MaskingRuleService { private final ProtectedObjectService protectedObjectService; private final AuditService auditService; private final MaskingPolicySynchronizer maskingPolicySynchronizer; + private final DataCatalog dataCatalog; + private final MaskingPolicyCatalog maskingPolicyCatalog; public MaskingRuleService( MaskingRuleMapper mapper, UserMapper userMapper, ProtectedObjectService protectedObjectService, AuditService auditService, - MaskingPolicySynchronizer maskingPolicySynchronizer + MaskingPolicySynchronizer maskingPolicySynchronizer, + DataCatalog dataCatalog, + MaskingPolicyCatalog maskingPolicyCatalog ) { this.mapper = mapper; this.userMapper = userMapper; this.protectedObjectService = protectedObjectService; this.auditService = auditService; this.maskingPolicySynchronizer = maskingPolicySynchronizer; + this.dataCatalog = dataCatalog; + this.maskingPolicyCatalog = maskingPolicyCatalog; } public List findAllRules() { @@ -55,9 +61,12 @@ public class MaskingRuleService { return mapper.findColumnRules(); } - /** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */ + /** Reads the actual Oracle Data Redaction state for configured managed objects. */ public List findPolicyStatuses() { - return mapper.findPolicyStatuses(); + if (maskingPolicyCatalog.targets().isEmpty()) { + return List.of(); + } + return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets()); } public Set managedObjectNames() { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 1b64004..0416c66 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -1,6 +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; @@ -9,26 +10,27 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; -/** MCP boundary exposing the Smilegate game-data Select AI generation and read-only execution tool. */ +/** MCP boundary exposing a configured Select AI generation and read-only execution tool. */ @Service public class McpSseService { - private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql"; private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)"; - private static final String DEFAULT_SELECT_AI_PROFILE = "SGMP_POC_OCI_GPT54MINI"; - private final SmilegateSelectAiService smilegateSelectAiService; + private final SelectAiService selectAiService; private final ObjectMapper objectMapper; private final BackofficeProperties properties; + private final McpProperties mcpProperties; public McpSseService( - SmilegateSelectAiService smilegateSelectAiService, + SelectAiService selectAiService, ObjectMapper objectMapper, - BackofficeProperties properties + BackofficeProperties properties, + McpProperties mcpProperties ) { - this.smilegateSelectAiService = smilegateSelectAiService; + this.selectAiService = selectAiService; this.objectMapper = objectMapper; this.properties = properties; + this.mcpProperties = mcpProperties; } public ObjectNode handle(String contextPath, JsonNode request) { @@ -93,7 +95,7 @@ public class McpSseService { private ObjectNode selectAiVpdQueryTool() { ObjectNode item = objectMapper.createObjectNode(); - item.put("name", SELECT_AI_VPD_QUERY_TOOL); + item.put("name", toolName()); item.put("description", selectAiVpdQueryView().description()); ObjectNode schema = objectMapper.createObjectNode(); @@ -102,7 +104,7 @@ public class McpSseService { ObjectNode prompt = objectMapper.createObjectNode(); prompt.put("type", "string"); - prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다."); + prompt.put("description", promptDescription()); prompt.put("maxLength", 4000); properties.set("prompt", prompt); @@ -117,7 +119,7 @@ public class McpSseService { private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { String toolName = params.path("name").asText(""); - if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) { + if (!toolName().equals(toolName)) { throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); } @@ -128,13 +130,13 @@ public class McpSseService { } JsonNode response; try { - response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText("")); + response = selectAiService.generateAndExecute(token, arguments.path("prompt").asText("")); } catch (VpdTokenAccessDeniedException ignored) { return tokenAccessDeniedResult(); } ObjectNode payload = objectMapper.createObjectNode(); - payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL); + payload.put("toolName", toolName()); payload.put("profile", selectAiProfile()); payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); payload.set("response", response); @@ -169,10 +171,10 @@ public class McpSseService { private McpToolView selectAiVpdQueryView() { String profile = selectAiProfile(); return new McpToolView( - SELECT_AI_VPD_QUERY_TOOL, - profile + " 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.", + toolName(), + profile + " 프로파일로 " + toolDescription(), -1L, - "Smilegate 게임 데이터 Text2SQL", + toolLabel(), SELECT_AI_VPD_QUERY_PATH ); } @@ -180,11 +182,29 @@ public class McpSseService { private String selectAiProfile() { BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) { - return DEFAULT_SELECT_AI_PROFILE; + return ""; } return selectAi.profile().trim(); } + private String toolName() { + return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName(); + } + + private String toolLabel() { + return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel(); + } + + private String toolDescription() { + return mcpProperties == null + ? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다." + : mcpProperties.resolvedToolDescription(); + } + + private String promptDescription() { + return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription(); + } + private String pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index 24fb4b9..e1f6139 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -23,20 +23,21 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SchemaMetadataService { - private static final String OWNER = "SGMP_POC"; 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}"); private final SchemaMetadataMapper mapper; private final StructuredDataService structuredDataService; + private final DataCatalog catalog; public SchemaMetadataService( SchemaMetadataMapper mapper, - StructuredDataService structuredDataService + StructuredDataService structuredDataService, DataCatalog catalog ) { this.mapper = mapper; this.structuredDataService = structuredDataService; + this.catalog = catalog; } public List tables() { @@ -54,7 +55,7 @@ public class SchemaMetadataService { List columns = columns(tableName, annotations); return new SchemaMetadataView( table, - nullToEmpty(mapper.findTableComment(OWNER, tableName)), + nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)), annotations.getOrDefault(tableTargetKey(), List.of()), columns ); @@ -65,7 +66,7 @@ public class SchemaMetadataService { StructuredDataTable table = structuredDataService.requireTable(tableKey); String tableName = requireSimpleName(table.tableName(), "table name"); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment"); - mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment)); + mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment)); } @Transactional @@ -74,7 +75,7 @@ public class SchemaMetadataService { String tableName = requireSimpleName(table.tableName(), "table name"); String column = requireColumn(tableName, columnName); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment"); - mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment)); + mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment)); } @Transactional @@ -106,16 +107,16 @@ public class SchemaMetadataService { String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value"); if (annotationExists(tableName, columnName, key)) { if (columnName == null) { - mapper.dropTableAnnotation(OWNER, tableName, key); + mapper.dropTableAnnotation(catalog.owner(), tableName, key); } else { - mapper.dropColumnAnnotation(OWNER, tableName, columnName, key); + mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key); } } if (!value.isBlank()) { if (columnName == null) { - mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value)); + mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value)); } else { - mapper.addColumnAnnotation(OWNER, tableName, columnName, key, quoteLiteral(value)); + mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value)); } } } @@ -124,7 +125,7 @@ public class SchemaMetadataService { String tableName, Map> annotations ) { - return mapper.findColumns(OWNER, tableName).stream() + return mapper.findColumns(catalog.owner(), tableName).stream() .map(row -> toColumn(row, annotations)) .toList(); } @@ -175,7 +176,7 @@ public class SchemaMetadataService { private String requireColumn(String tableName, String columnName) { String column = requireSimpleName(columnName, "column name"); - if (mapper.countColumn(OWNER, tableName, column) == 0) { + if (mapper.countColumn(catalog.owner(), tableName, column) == 0) { throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); } return column; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java index 92b3f50..1367d6f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -1,11 +1,15 @@ 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.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; @@ -17,25 +21,18 @@ import org.springframework.stereotype.Service; @Service public class SecuritySqlScriptService { - private static final List CURATED_SCRIPTS = List.of( - new ScriptDefinition( - "smilegate-tool-users", - "Smilegate 사용자", - "70_sg_tool_user.sql", - "PoC 도구 사용자 초기 데이터", - "Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다." - ), - new ScriptDefinition( - "smilegate-identity-administration", - "Smilegate 권한", - "71_sg_identity_administration.sql", - "사용자·그룹·역할 관리 모델", - "Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다." - ) + 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(), @@ -47,7 +44,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 스크립트입니다.")); @@ -70,7 +67,36 @@ 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() || parsed.stream().map(ScriptDefinition::scriptId).distinct().count() != parsed.size()) { + throw new IllegalArgumentException(); + } + parsed.forEach(this::validate); + 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(); + } + } + + 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/SmilegateSelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java similarity index 94% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java rename to src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 8b77714..2d166a0 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -21,10 +21,10 @@ import java.util.regex.Pattern; import org.springframework.stereotype.Service; /** - * Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile. + * Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile. */ @Service -public class SmilegateSelectAiService { +public class SelectAiService { private static final int MAX_PROMPT_LENGTH = 4_000; private static final int MAX_RESULT_ROWS = 100; @@ -40,7 +40,7 @@ public class SmilegateSelectAiService { private final Clock clock; private final ObjectMapper objectMapper; - public SmilegateSelectAiService( + public SelectAiService( BackofficeProperties properties, BearerTokenService bearerTokenService, Clock clock, @@ -57,7 +57,7 @@ public class SmilegateSelectAiService { String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || !selectAi.configured()) { - throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. " + throw new AppException("Select AI 연결 설정이 필요합니다. " + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요."); } @@ -117,7 +117,7 @@ public class SmilegateSelectAiService { } catch (AppException exception) { throw exception; } catch (Exception exception) { - throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage()); + throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage()); } } @@ -155,7 +155,7 @@ public class SmilegateSelectAiService { connection.rollback(); } } catch (Exception exception) { - throw new AppException("Smilegate Select AI 생성 SQL 실행 실패: " + exception.getMessage()); + throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage()); } return new QueryExecution(items, truncated); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index f2d09f5..bf36e5d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -11,36 +11,25 @@ import org.springframework.stereotype.Service; @Service public class StructuredDataService { - private static final String OWNER = "SGMP_POC"; private static final int ROW_LIMIT = 50; - private static final List TABLES = List.of( - new StructuredDataTable("game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터"), - new StructuredDataTable("characters", "CZN_COMN_CHARACTER_MST", "캐릭터", "카제나 캐릭터 마스터"), - new StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"), - new StructuredDataTable("refunds", "COMN_REFUND_TXN", "환불 거래", "게임 상품 환불 거래"), - new StructuredDataTable("products", "COMN_SALES_PRODUCT_DISP_BAS", "상품", "판매 상품 전시 기준"), - new StructuredDataTable("game-servers", "COMN_GAME_SERVER_BAS", "게임 서버", "게임 서버 기준 정보"), - new StructuredDataTable("game-aliases", "COMN_GAME_ALIAS_BAS", "게임 별칭", "게임명·별칭·prefix 매핑")); - private final JdbcTemplate jdbcTemplate; + private final DataCatalog catalog; - public StructuredDataService(JdbcTemplate jdbcTemplate) { + public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) { this.jdbcTemplate = jdbcTemplate; + this.catalog = catalog; } public List tables() { - return TABLES; + return catalog.objects(); } public String defaultKey() { - return TABLES.getFirst().key(); + return catalog.objects().getFirst().key(); } public StructuredDataTable requireTable(String key) { - return TABLES.stream() - .filter(table -> table.key().equals(key)) - .findFirst() - .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); + return catalog.require(key); } public StructuredDataPreview preview(String key) { @@ -54,7 +43,7 @@ public class StructuredDataService { 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("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); } @@ -63,24 +52,11 @@ public class StructuredDataService { previewSql(table), ROW_LIMIT); return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); } catch (DataAccessException exception) { - throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요."); + throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요."); } } - /** - * 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 "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?"; - case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?"; - case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?"; - case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?"; - case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?"; - case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?"; - case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?"; - default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); - }; + return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.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/ProductModelAdvice.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java new file mode 100644 index 0000000..aaaeadf --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java @@ -0,0 +1,25 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.config.ProductProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; +import com.cloudhandson.vpdbackoffice.service.DataCatalog; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +@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/SchemaMetadataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java index fae93a8..d31c128 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java @@ -114,6 +114,6 @@ public class SchemaMetadataController { private String readMessage(Exception exception) { return exception instanceof AppException ? exception.getMessage() - : "DB 메타데이터를 조회하지 못했습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요."; + : "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요."; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ea6f2df..bf1d191 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,9 +69,25 @@ 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: - # Cloud AI profiles are schema-owned. This connection must use SGMP_POC, - # not the ADMIN connection used by the backoffice control plane. + # Cloud AI profiles are schema-owned. This connection must use the profile owner's account, + # not the control-plane account used by the backoffice. 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:SGMP_POC_OCI_GPT54MINI} + 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: + 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을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.} + prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.} + masking: + policies: ${BACKOFFICE_MASKING_POLICIES:} + security-sql-scripts: + scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:} diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/src/main/resources/mapper/MaskingRuleMapper.xml index e82c4a7..a4609ab 100644 --- a/src/main/resources/mapper/MaskingRuleMapper.xml +++ b/src/main/resources/mapper/MaskingRuleMapper.xml @@ -68,10 +68,9 @@ -->