From f452b05209cb5e21214a93f71fe5522122364ad0 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 +++++ vpd-backoffice/pom.xml | 13 +- .../config/BackofficeProperties.java | 2 +- .../config/CatalogProperties.java | 1 - .../vpdbackoffice/config/DbPoolWarmup.java | 2 +- .../config/MaskingProperties.java | 2 +- .../vpdbackoffice/config/McpProperties.java | 21 +- .../config/ProductProperties.java | 16 +- .../structured/StructuredDataTable.java | 12 +- .../mapper/MaskingRuleMapper.java | 2 +- .../service/BackofficeSchemaService.java | 16 +- .../vpdbackoffice/service/DataCatalog.java | 3 - .../service/EnvironmentDataCatalog.java | 106 ++----- .../EnvironmentMaskingPolicyCatalog.java | 37 +-- .../service/MaskingPolicyCatalog.java | 10 +- .../service/MaskingPolicySynchronizer.java | 30 +- .../service/MaskingPolicyTarget.java | 2 +- .../service/MaskingRuleService.java | 15 +- .../vpdbackoffice/service/McpSseService.java | 52 ++-- .../service/SchemaMetadataService.java | 22 +- .../service/SecuritySqlScriptService.java | 62 ++-- .../service/SelectAiService.java | 266 +++++------------- .../service/SmilegateSelectAiService.java | 12 +- .../service/StructuredDataService.java | 55 +--- .../vpdbackoffice/web/ProductModelAdvice.java | 26 +- .../web/SchemaMetadataController.java | 2 +- .../src/main/resources/application.yml | 22 +- .../resources/mapper/MaskingRuleMapper.xml | 28 +- .../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 | 13 +- .../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 +- 42 files changed, 449 insertions(+), 618 deletions(-) create mode 100644 docs/design/722-configurable-data-catalog/README.md diff --git a/.env.example b/.env.example index c5f4582..8478479 100644 --- a/.env.example +++ b/.env.example @@ -52,12 +52,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/vpd-backoffice/pom.xml b/vpd-backoffice/pom.xml index 1c3329c..14b770c 100644 --- a/vpd-backoffice/pom.xml +++ b/vpd-backoffice/pom.xml @@ -92,15 +92,12 @@ src/main/resources - + - ../database/adb - database/adb - - 70_sg_tool_user.sql - 71_sg_identity_administration.sql - + sql/adb + sql/adb + **/*.sql diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 804fcec..c980efc 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/vpd-backoffice/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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java index 38f20df..d2e546c 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java @@ -2,7 +2,6 @@ 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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java index 85e1faa..b11d1c4 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java +++ b/vpd-backoffice/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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java index ed31458..de1519f 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java @@ -2,7 +2,7 @@ package com.cloudhandson.vpdbackoffice.config; import org.springframework.boot.context.properties.ConfigurationProperties; -/** JSON configuration of database redaction policies this backoffice may manage. */ +/** JSON configuration of database redaction policies this backoffice is allowed to manage. */ @ConfigurationProperties(prefix = "backoffice.masking") public record MaskingProperties(String policies) { } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java index acf1c19..f052933 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -2,36 +2,23 @@ package com.cloudhandson.vpdbackoffice.config; import org.springframework.boot.context.properties.ConfigurationProperties; -/** Product-neutral MCP endpoint labels and tool catalogue configuration. */ +/** Product-neutral labels and endpoint details for the MCP Select AI tool. */ @ConfigurationProperties(prefix = "backoffice.mcp") public record McpProperties( - String publicUrl, - String serverName, String toolName, String toolLabel, String toolDescription, - String promptDescription, - String tools + String promptDescription ) { - 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을 생성하고, 검증 후 읽기 전용 " - + "트랜잭션에서 실행합니다."; + "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. " + + "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다."; 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); } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java index a311670..6dd6bdc 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java @@ -2,19 +2,9 @@ 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(); - } + 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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index 446c88a..b1f19b3 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -1,21 +1,13 @@ package com.cloudhandson.vpdbackoffice.domain.structured; -import java.util.List; - public record StructuredDataTable( String key, String tableName, String objectType, String businessName, - String description, - List previewColumns + String description ) { - public StructuredDataTable(String key, String tableName, String businessName, String description) { - this(key, tableName, "TABLE", businessName, description, List.of()); - } - - public boolean isTable() { - return "TABLE".equals(objectType); + this(key, tableName, "TABLE", businessName, description); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index 7c908ee..2224664 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -31,7 +31,7 @@ public interface MaskingRuleMapper { List findPolicyStatuses( @Param("owner") String owner, - @Param("policies") List policies + @Param("targets") List targets ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 5f88dfc..dd538ab 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/vpd-backoffice/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() { @@ -712,7 +718,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. 비면제 업무 runtime 사용자에 필요한 최소 권한 @@ -720,9 +726,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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java index b63fede..1c4cfd3 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java @@ -4,10 +4,7 @@ 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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java index 0f07ffa..990f5b0 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java @@ -4,119 +4,51 @@ 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); + 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() + @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) { + private List parse(String raw, ObjectMapper mapper) { 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); + 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 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 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 field) { - String normalized = requiredText(value, field).toUpperCase(Locale.ROOT); - if (!NAME.matcher(normalized).matches()) { - throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다."); - } + 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; } - - private String requiredText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(field + " 값은 필수입니다."); - } - return value.trim(); - } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java index e6559ab..11e3079 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java @@ -3,25 +3,18 @@ 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. */ +/** 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 - ) { + public EnvironmentMaskingPolicyCatalog(MaskingProperties properties, ObjectMapper objectMapper) { targets = parse(properties.policies(), objectMapper); } @@ -36,28 +29,12 @@ public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog { } try { List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); - if (parsed.isEmpty()) { - throw new IllegalArgumentException("마스킹 정책 목록이 비어 있습니다."); + if (parsed.isEmpty() + || parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) { + 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()); - } - }) + .map(item -> new MaskingPolicyTarget(normalize(item.objectName()), normalize(item.policyName()))) .toList(); return List.copyOf(normalized); } catch (Exception exception) { @@ -68,7 +45,7 @@ public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog { private String normalize(String value) { String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); if (!NAME.matcher(normalized).matches()) { - throw new IllegalArgumentException("Oracle 식별자 형식이 올바르지 않습니다."); + throw new IllegalArgumentException(); } return normalized; } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java index d224b67..e176b8b 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java @@ -1,22 +1,16 @@ 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()); + 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(Locale.ROOT)); + return objectName != null && objectNames().contains(objectName.trim().toUpperCase(java.util.Locale.ROOT)); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index b40709c..b8ef453 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -23,7 +23,6 @@ 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 final JdbcTemplate jdbcTemplate; @@ -39,41 +38,18 @@ public class MaskingPolicySynchronizer { ) { 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 policyCatalog.objectNames(); } - public String owner() { - return dataCatalog.owner(); - } - public boolean isManagedObject(String objectName) { return policyCatalog.containsObject(objectName); } - String managedPolicyName(String objectName) { - return policyCatalog.targets().stream() - .filter(target -> target.objectName().equals(objectName)) - .map(MaskingPolicyTarget::policyName) - .findFirst() - .orElse(null); - } - - List managedPolicies() { - return policyCatalog.targets(); - } - /** * Applies the current active column-rule metadata to managed DBMS_REDACT policies. * @@ -331,7 +307,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, dataCatalog.owner(), objectName, columnName, expressionName); + """, dataCatalog.owner(), objectName, columnName, expressionName); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java index 2f60dc3..7da5387 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java @@ -1,5 +1,5 @@ package com.cloudhandson.vpdbackoffice.service; -/** A validated database object-to-redaction-policy mapping. */ +/** A validated object-to-redaction-policy mapping supplied by deployment configuration. */ public record MaskingPolicyTarget(String objectName, String policyName) { } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index 70df7d5..bd5d064 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/vpd-backoffice/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,13 +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() { - List policies = maskingPolicySynchronizer.managedPolicies(); - if (policies.isEmpty()) { + if (maskingPolicyCatalog.targets().isEmpty()) { return List.of(); } - return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies); + return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets()); } public Set managedObjectNames() { diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 1b64004..0416c66 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/vpd-backoffice/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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index 43c3584..92ce7eb 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -29,13 +29,15 @@ public class SchemaMetadataService { 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() { @@ -57,7 +59,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 ); @@ -68,7 +70,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 @@ -77,7 +79,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 @@ -109,16 +111,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)); } } } @@ -127,7 +129,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(); } @@ -178,7 +180,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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java index 92b3f50..1367d6f 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ b/vpd-backoffice/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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 453bb4c..2d166a0 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -1,27 +1,28 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; 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.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; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.Statement; -import java.util.Locale; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.regex.Pattern; import org.springframework.stereotype.Service; -/** Generates and executes bounded read-only SQL through a configured Select AI profile. */ +/** + * Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile. + */ @Service public class SelectAiService { @@ -35,47 +36,35 @@ public class SelectAiService { ); private final BackofficeProperties properties; - private final HmmMcpBearerAuthenticator bearerAuthenticator; + private final BearerTokenService bearerTokenService; + private final Clock clock; private final ObjectMapper objectMapper; public SelectAiService( BackofficeProperties properties, - HmmMcpBearerAuthenticator bearerAuthenticator, + BearerTokenService bearerTokenService, + Clock clock, ObjectMapper objectMapper ) { this.properties = properties; - this.bearerAuthenticator = bearerAuthenticator; + this.bearerTokenService = bearerTokenService; + this.clock = clock; this.objectMapper = objectMapper; } public JsonNode generateAndExecute(String bearerToken, String prompt) { - HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken); + requireActiveToken(bearerToken); String normalizedPrompt = requiredPrompt(prompt); - BackofficeProperties.SelectAi selectAi = - properties == null ? null : properties.selectAi(); + 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을 확인하세요."); - } - 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를 확인하세요."); + + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요."); } - String generatedSql = generate( - selectAi, - vpdAwarePrompt( - normalizedPrompt, - principal, - loadQueryContract(selectAi.queryContractFile()) - ) - ); + String generatedSql = generate(selectAi, normalizedPrompt); String normalizedSql = validateReadOnlySql(generatedSql); - QueryExecution execution = executeReadOnly( - selectAi, normalizedSql, bearerToken.trim(), principal); + QueryExecution execution = executeReadOnly(selectAi, normalizedSql); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); @@ -83,15 +72,24 @@ 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 + "건만 반환했습니다." + ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다." : "생성 SQL을 읽기 전용으로 실행한 결과입니다."); return response; } + private void requireActiveToken(String bearerToken) { + if (bearerToken == null || bearerToken.isBlank()) { + throw new VpdTokenAccessDeniedException(); + } + BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim()); + LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault())); + if (token == null || !token.active(now)) { + throw new VpdTokenAccessDeniedException(); + } + } + private String requiredPrompt(String prompt) { String normalized = prompt == null ? "" : prompt.trim(); if (normalized.isEmpty()) { @@ -103,57 +101,7 @@ public class SelectAiService { return normalized; } - private String vpdAwarePrompt( - String prompt, - HmmMcpPrincipal principal, - String queryContract - ) { - String basePrompt = """ - Oracle SQL 생성 규칙: - - 프로필에 승인된 HMM HR 객체만 사용하세요. - - 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요. - - 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요. - - 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요. - - 사용자 질문: %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( - BackofficeProperties.SelectAi selectAi, - String prompt - ) { + 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()); @@ -169,126 +117,50 @@ public class SelectAiService { } catch (AppException exception) { throw exception; } catch (Exception exception) { - throw new AppException("Select AI SHOWSQL 생성 실패: " + safeMessage(exception)); + throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage()); } } - private QueryExecution executeReadOnly( - BackofficeProperties.SelectAi selectAi, - String generatedSql, - String bearerToken, - HmmMcpPrincipal principal - ) { + private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) { ArrayNode items = objectMapper.createArrayNode(); boolean truncated = false; try (Connection connection = DriverManager.getConnection( - selectAi.runtimeDbUrl(), - selectAi.runtimeDbUsername(), - selectAi.runtimeDbPassword())) { - verifyNonExemptRuntime(connection); - boolean contextSet = false; - try { - try (CallableStatement statement = connection.prepareCall( - "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()) { - 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, columnIndex); + selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); + Statement transaction = connection.createStatement()) { + connection.setAutoCommit(false); + connection.setReadOnly(true); + 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 CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) { - statement.execute(); - } - } - } + connection.rollback(); } } catch (Exception exception) { - throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception)); + throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage()); } return new QueryExecution(items, truncated); } - 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); + private void putResultValue(ObjectNode row, String column, Object value) { if (value == null) { row.putNull(column); } else if (value instanceof BigDecimal number) { @@ -308,13 +180,11 @@ public class SelectAiService { } else if (value instanceof Boolean bool) { row.put(column, bool); } else { - // 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)); + row.put(column, String.valueOf(value)); } } - String validateReadOnlySql(String generatedSql) { + private String validateReadOnlySql(String generatedSql) { String normalized = generatedSql == null ? "" : generatedSql.trim(); if (normalized.startsWith("```")) { int firstLineEnd = normalized.indexOf('\n'); @@ -328,24 +198,14 @@ public class SelectAiService { throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다."); } if (normalized.contains(";")) { - throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 실행하지 않습니다."); + throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다."); } - if (normalized.contains("--") - || normalized.contains("/*") - || normalized.contains("*/") + 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) { - } + private record QueryExecution(ArrayNode items, boolean truncated) {} } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java index 8b77714..2d166a0 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.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/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index 09f121d..bf36e5d 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -12,26 +12,14 @@ import org.springframework.stereotype.Service; public class StructuredDataService { private static final int ROW_LIMIT = 50; - private final JdbcTemplate jdbcTemplate; private final DataCatalog catalog; - public StructuredDataService( - JdbcTemplate jdbcTemplate, - DataCatalog catalog - ) { + public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) { this.jdbcTemplate = jdbcTemplate; this.catalog = catalog; } - public DataCatalog catalog() { - return catalog; - } - - public String owner() { - return catalog.owner(); - } - public List tables() { return catalog.objects(); } @@ -54,48 +42,21 @@ public class StructuredDataService { WHERE owner = ? AND table_name = ? ORDER BY column_id - """, + """, (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("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다."); - } + List> rows = jdbcTemplate.queryForList( - previewSql(table, previewColumns), ROW_LIMIT); - return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT); + previewSql(table), ROW_LIMIT); + return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); } catch (DataAccessException exception) { - throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner() - + " 조회 권한과 대상 객체 상태를 확인하세요."); + 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. - */ - 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 <= ?"; + private String previewSql(StructuredDataTable table) { + return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?"; } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java index 7af8a5e..aaaeadf 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java @@ -1,41 +1,25 @@ package com.cloudhandson.vpdbackoffice.web; -import com.cloudhandson.vpdbackoffice.config.McpProperties; 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; -/** 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 - ) { + public ProductModelAdvice(ProductProperties product, DataCatalog catalog, McpProperties mcp) { this.product = product; this.catalog = catalog; this.mcp = mcp; } - @ModelAttribute("product") - ProductProperties product() { - return product; - } - + ProductProperties product() { return product; } @ModelAttribute("catalogOwner") - String catalogOwner() { - return catalog.owner(); - } - + String catalogOwner() { return catalog.owner(); } @ModelAttribute("mcp") - McpProperties mcp() { - return mcp; - } + McpProperties mcp() { return mcp; } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java index b334125..1974f5a 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java @@ -115,6 +115,6 @@ public class SchemaMetadataController { private String readMessage(Exception exception) { return exception instanceof AppException ? exception.getMessage() - : "DB 메타데이터를 조회하지 못했습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요."; + : "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요."; } } diff --git a/vpd-backoffice/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml index 483842c..4b0262b 100644 --- a/vpd-backoffice/src/main/resources/application.yml +++ b/vpd-backoffice/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/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml index 23a563c..5a37a68 100644 --- a/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml @@ -68,10 +68,9 @@ --> - 현재 관리 대상 ASO 정책이 있는 게임 데이터 객체만 표시됩니다. 예: SGMP_POC.COMN_SALES_TXN.GUID. + 현재 관리 대상 ASO 정책이 있는 등록 업무 데이터 객체만 표시됩니다. 예: OWNER.OBJECT_NAME.COLUMN_NAME. @@ -253,7 +253,7 @@ 대상 컬럼규칙템플릿백오피스 설정DB ASO 적용 상태 - SGMP_POC.CZN_COMN_USER_MST.USER_ID + OWNER.OBJECT_NAME.COLUMN_NAME 게임 사용자 식별자 기본 마스킹 식별자 부분 마스킹 기본 규칙 연결됨 diff --git a/vpd-backoffice/src/main/resources/templates/mcp-sse.html b/vpd-backoffice/src/main/resources/templates/mcp-sse.html index feffd5f..6fe2ea4 100644 --- a/vpd-backoffice/src/main/resources/templates/mcp-sse.html +++ b/vpd-backoffice/src/main/resources/templates/mcp-sse.html @@ -8,7 +8,7 @@

MCP 연동

도움말 -

사용자 Bearer Token을 검증한 뒤 SGMP_POC_OCI_GPT54MINI Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.

+

사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일로 업무 데이터 Text2SQL을 생성하는 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.

@@ -91,7 +91,7 @@
ORDS 행 접근 조회 도구 설명
- HTTP Authorization → 활성 PoC 사용자 토큰 검증 · prompt → 게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행 + HTTP Authorization → 활성 사용자 토큰 검증 · prompt → 업무 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행 @@ -108,9 +108,9 @@ tools/call parameter 예시 보기

tools/call Arguments

{
-  "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
+  "prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
 }
-

등록 tool은 oracle.select_ai.smilegate_game_text2sql 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 SGMP_POC_OCI_GPT54MINI가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.

+

등록 tool은 oracle.select_ai.data_text2sql 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 Select AI가 comment, annotation, constraint를 참고해 읽기 전용 업무 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.

@@ -144,9 +144,9 @@ "id": 3, "method": "tools/call", "params": { - "name": "oracle.select_ai.smilegate_game_text2sql", + "name": "oracle.select_ai.data_text2sql", "arguments": { - "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘." + "prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘." } } } diff --git a/vpd-backoffice/src/main/resources/templates/operation-status.html b/vpd-backoffice/src/main/resources/templates/operation-status.html index dd039c9..db64ea6 100644 --- a/vpd-backoffice/src/main/resources/templates/operation-status.html +++ b/vpd-backoffice/src/main/resources/templates/operation-status.html @@ -159,8 +159,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼상태확인 결과 - SGMP_POC.COMN_SALES_TXN - SG_SALES_TXN_REDACT + OWNER.OBJECT_NAME + REDACTION_POLICY 0 0 적용됨 diff --git a/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html b/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html index 4d58a18..31c06d4 100644 --- a/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html +++ b/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html @@ -29,7 +29,7 @@ ASO / 마스킹 - 71_sg_identity_administration.sql + category/script.sql 사용자·그룹·역할 관리 모델
설명
@@ -45,13 +45,13 @@
- Smilegate 권한 + 권한

사용자·그룹·역할 관리 모델

설명

- 71_sg_identity_administration.sql + category/script.sql
-

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

+

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

diff --git a/vpd-backoffice/src/main/resources/templates/structured-data.html b/vpd-backoffice/src/main/resources/templates/structured-data.html index edbc371..3e03cd9 100644 --- a/vpd-backoffice/src/main/resources/templates/structured-data.html +++ b/vpd-backoffice/src/main/resources/templates/structured-data.html @@ -8,20 +8,19 @@

정형 데이터 조회

도움말 -

환경 설정에 등록된 업무 데이터 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.

-

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

+

등록된 업무 데이터 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.

- 이 화면은 관리자용 원장 미리보기입니다. 사용자별 행 접근 적용 결과는 접근 검증에서 확인하세요. + 이 화면은 관리자용 업무 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 접근 검증에서 확인하세요.

조회할 업무 데이터 선택

-

OWNER 스키마에서 환경 설정으로 승인한 TABLE/VIEW만 표시합니다.

+

OWNER 스키마에서 등록한 업무 데이터 객체만 표시합니다.

@@ -29,9 +28,9 @@ class="structured-table-card" th:classappend="${entry.key() == selectedKey} ? ' is-selected'" th:href="@{/structured-data(table=${entry.key()})}"> - 직원 원장 + 게임 사용자 OBJECT_NAME - TABLE · 업무 데이터 + 게임 사용자 마스터
@@ -43,7 +42,7 @@

직원 원장

- OWNER.OBJECT_NAME + OWNER.TABLE_NAME · 최대 50건

diff --git a/vpd-backoffice/src/main/resources/templates/tokens.html b/vpd-backoffice/src/main/resources/templates/tokens.html index 4ed66c4..45c6b8b 100644 --- a/vpd-backoffice/src/main/resources/templates/tokens.html +++ b/vpd-backoffice/src/main/resources/templates/tokens.html @@ -1,11 +1,11 @@ - +
-

Smilegate 액세스 토큰

+

액세스 토큰

도움말

Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.

diff --git a/vpd-backoffice/src/main/resources/templates/user-masking-rules.html b/vpd-backoffice/src/main/resources/templates/user-masking-rules.html index 749f854..1dbd7ef 100644 --- a/vpd-backoffice/src/main/resources/templates/user-masking-rules.html +++ b/vpd-backoffice/src/main/resources/templates/user-masking-rules.html @@ -39,7 +39,7 @@ data-result=${columnRule.template().previewResult()}, data-aso-function=${columnRule.template().asoFunction()}, data-context=${'MR_' + columnRule.columnId()}" - th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID + th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME
@@ -86,7 +86,7 @@ sg-teamlead - SGMP_POC.CZN_COMN_USER_MST.GUID + OWNER.OBJECT_NAME.COLUMN_NAME 게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹 원문 표시 예외 Y diff --git a/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html b/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html index d35bc2e..695f259 100644 --- a/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html +++ b/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html @@ -82,8 +82,8 @@ - SGMP_POC.CZN_COMN_USER_MST - SG_CZN_USER_ROW_POLICY + OWNER.OBJECT_NAME + ROW_ACCESS_POLICY SELECT 적용됨 ADMIN.CB_AGENT_DOC_VPD_FILTER diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java index 73e3ff6..88afa29 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java @@ -23,8 +23,9 @@ class MaskingPolicySynchronizerTest { } @Test - void smilegateSalesTransactionPolicyIsManagedByBackoffice() { - assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN")) - .isEqualTo("SG_SALES_TXN_REDACT"); + void redactionPolicyManagementIsNotBoundToAProductSpecificMapping() { + assertThat(MaskingPolicySynchronizer.class.getDeclaredFields()) + .extracting(field -> field.getName()) + .doesNotContain("MANAGED_POLICIES", "OWNER"); } } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index bd84be6..873a0ee 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -10,7 +11,7 @@ import org.junit.jupiter.api.Test; class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); - private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService(); + private final SelectAiService selectAiService = new CapturingSelectAiService(); private final McpSseService service = new McpSseService( selectAiService, objectMapper, @@ -20,6 +21,12 @@ class McpSseServiceTest { null, null, new BackofficeProperties.SelectAi("", "", "", "SGMP_POC_OCI_GPT54MINI") + ), + new McpProperties( + "oracle.select_ai.test_data_text2sql", + "테스트 데이터 Text2SQL", + "테스트 데이터용 읽기 전용 SQL을 생성하고 실행합니다.", + "테스트 데이터의 조회 내용을 입력합니다." ) ); @@ -30,7 +37,7 @@ class McpSseServiceTest { var tools = response.path("result").path("tools"); assertThat(tools).hasSize(1); var selectAi = tools.get(0); - assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql"); + assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql"); assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI"); assertThat(selectAi.path("inputSchema").path("required")) .extracting(node -> node.asText()) @@ -44,14 +51,13 @@ class McpSseServiceTest { void callsVpdSelectAiThroughOrdsService() { ObjectNode request = request(2, "tools/call"); ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "oracle.select_ai.smilegate_game_text2sql"); + params.put("name", "oracle.select_ai.test_data_text2sql"); ObjectNode arguments = params.putObject("arguments"); arguments.put("prompt", "카제나 AU를 조회해 줘"); ObjectNode response = service.handle("default", request, "user-bearer"); - CapturingSmilegateSelectAiService agentService = - (CapturingSmilegateSelectAiService) selectAiService; + CapturingSelectAiService agentService = (CapturingSelectAiService) selectAiService; assertThat(agentService.bearerToken).isEqualTo("user-bearer"); assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘"); assertThat(response.path("error").isMissingNode()).isTrue(); @@ -69,7 +75,7 @@ class McpSseServiceTest { void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { ObjectNode request = request(3, "tools/call"); ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "oracle.select_ai.smilegate_game_text2sql"); + params.put("name", "oracle.select_ai.test_data_text2sql"); params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘"); ObjectNode response = service.handle("default", request, ""); @@ -89,12 +95,12 @@ class McpSseServiceTest { return request; } - private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService { + private static final class CapturingSelectAiService extends SelectAiService { private String bearerToken; private String prompt; - private CapturingSmilegateSelectAiService() { + private CapturingSelectAiService() { super(null, null, null, new ObjectMapper()); } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java index 7147b54..795af11 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataServiceTest.java @@ -29,10 +29,12 @@ class SchemaMetadataServiceTest { void setUp() { mapper = mock(SchemaMetadataMapper.class); StructuredDataService structuredDataService = mock(StructuredDataService.class); + DataCatalog catalog = mock(DataCatalog.class); + when(catalog.owner()).thenReturn("SGMP_POC"); when(structuredDataService.requireTable("game-users")).thenReturn(GAME_USERS); when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS)); when(structuredDataService.defaultKey()).thenReturn("game-users"); - service = new SchemaMetadataService(mapper, structuredDataService); + service = new SchemaMetadataService(mapper, structuredDataService, catalog); } @Test diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java index f738c2a..4271287 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java @@ -3,22 +3,31 @@ 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":"tool-users","category":"사용자","fileName":"legacy/smilegate/70_tool_user.sql", + "title":"도구 사용자","description":"도구 사용자 초기 데이터"}, + {"scriptId":"identity-administration","category":"권한","fileName":"legacy/smilegate/71_identity_administration.sql", + "title":"권한 관리","description":"권한 메타데이터"}] + """), + new ObjectMapper() + ); @Test void exposesOnlyTheCuratedGitTrackedSecurityScripts() { assertThat(service.list()) .extracting(item -> item.fileName()) .containsExactly( - "70_sg_tool_user.sql", - "71_sg_identity_administration.sql" + "legacy/smilegate/70_tool_user.sql", "legacy/smilegate/71_identity_administration.sql" ); - assertThat(service.find("smilegate-identity-administration").source()) + assertThat(service.find("identity-administration").source()) .contains("create table sg_app_user") .contains("DATA_AI_TF"); } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java index a0dc7dd..6920701 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java @@ -6,29 +6,30 @@ import static org.mockito.Mockito.mock; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import java.util.List; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; class StructuredDataServiceTest { - private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); + private final DataCatalog catalog = mock(DataCatalog.class); + private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class), catalog); @Test void exposesOnlyTheSevenApprovedSmilegateStructuredTables() { + when(catalog.objects()).thenReturn(List.of( + new com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"))); assertThat(service.tables()) .extracting(table -> table.tableName()) - .containsExactly( - "CZN_COMN_USER_MST", - "CZN_COMN_CHARACTER_MST", - "COMN_SALES_TXN", - "COMN_REFUND_TXN", - "COMN_SALES_PRODUCT_DISP_BAS", - "COMN_GAME_SERVER_BAS", - "COMN_GAME_ALIAS_BAS"); + .containsExactly("COMN_SALES_TXN"); } @Test void rejectsAnyTableOutsideTheServerSideAllowlist() { + doThrow(new AppException("선택할 수 없는 카탈로그 객체입니다.")) + .when(catalog).require("security-audit-log"); assertThatThrownBy(() -> service.requireTable("security-audit-log")) .isInstanceOf(AppException.class) - .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); + .hasMessage("선택할 수 없는 카탈로그 객체입니다."); } }