refs #722: externalize backoffice customer configuration
This commit is contained in:
22
.env.example
22
.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_USERNAME="CB_ORDS"
|
||||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
||||||
|
|
||||||
# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다.
|
# Select AI는 프로파일 소유 스키마로 별도 접속합니다.
|
||||||
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
|
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
|
||||||
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
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_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 탭) ---
|
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||||
export BACKOFFICE_AI_ENABLED="false"
|
export BACKOFFICE_AI_ENABLED="false"
|
||||||
|
|||||||
78
docs/design/722-configurable-data-catalog/README.md
Normal file
78
docs/design/722-configurable-data-catalog/README.md
Normal file
@@ -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/<customer>/`에 보존하고, 다른 환경에는 해당 목록을
|
||||||
|
선언하지 않는다.
|
||||||
|
|
||||||
|
## 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를 자동으로 생성·변경하지 않는다.
|
||||||
9
pom.xml
9
pom.xml
@@ -92,15 +92,12 @@
|
|||||||
<resource>
|
<resource>
|
||||||
<directory>src/main/resources</directory>
|
<directory>src/main/resources</directory>
|
||||||
</resource>
|
</resource>
|
||||||
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
|
<!-- Keep reviewed SQL in sql/adb. Runtime exposure remains restricted by
|
||||||
only the curated read-only viewer set into the deployed JAR. -->
|
BACKOFFICE_SECURITY_SQL_SCRIPTS, never by a request path. -->
|
||||||
<resource>
|
<resource>
|
||||||
<directory>sql/adb</directory>
|
<directory>sql/adb</directory>
|
||||||
<targetPath>sql/adb</targetPath>
|
<targetPath>sql/adb</targetPath>
|
||||||
<includes>
|
<includes><include>**/*.sql</include></includes>
|
||||||
<include>70_sg_tool_user.sql</include>
|
|
||||||
<include>71_sg_identity_administration.sql</include>
|
|
||||||
</includes>
|
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
@EnableConfigurationProperties({
|
||||||
|
BackofficeProperties.class,
|
||||||
|
CatalogProperties.class,
|
||||||
|
MaskingProperties.class,
|
||||||
|
McpProperties.class,
|
||||||
|
ProductProperties.class,
|
||||||
|
SecuritySqlScriptProperties.class
|
||||||
|
})
|
||||||
public class AppConfig {
|
public class AppConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -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(
|
public record SelectAi(
|
||||||
String dbUrl,
|
String dbUrl,
|
||||||
String dbUsername,
|
String dbUsername,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -51,6 +51,6 @@ public class DbPoolWarmup {
|
|||||||
groupService.findGroupRoles();
|
groupService.findGroupRoles();
|
||||||
permissionService.findRoles();
|
permissionService.findRoles();
|
||||||
permissionService.findPermissionViews();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.domain.structured;
|
|||||||
public record StructuredDataTable(
|
public record StructuredDataTable(
|
||||||
String key,
|
String key,
|
||||||
String tableName,
|
String tableName,
|
||||||
|
String objectType,
|
||||||
String businessName,
|
String businessName,
|
||||||
String description
|
String description
|
||||||
) {
|
) {
|
||||||
|
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
||||||
|
this(key, tableName, "TABLE", businessName, description);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
@@ -28,7 +29,10 @@ public interface MaskingRuleMapper {
|
|||||||
|
|
||||||
List<ColumnMaskingRule> findColumnRules();
|
List<ColumnMaskingRule> findColumnRules();
|
||||||
|
|
||||||
List<MaskingPolicyStatus> findPolicyStatuses();
|
List<MaskingPolicyStatus> findPolicyStatuses(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("targets") List<MaskingPolicyTarget> targets
|
||||||
|
);
|
||||||
|
|
||||||
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
||||||
|
|
||||||
|
|||||||
@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final BackofficeProperties properties;
|
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.jdbcTemplate = jdbcTemplate;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.dataCatalog = dataCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SchemaPreflightView preflight() {
|
public SchemaPreflightView preflight() {
|
||||||
@@ -705,7 +711,7 @@ public class BackofficeSchemaService {
|
|||||||
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
||||||
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
||||||
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.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
|
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||||
|
|
||||||
-- 2. ORDS parsing schema로 접속
|
-- 2. ORDS parsing schema로 접속
|
||||||
@@ -717,9 +723,9 @@ public class BackofficeSchemaService {
|
|||||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||||
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
||||||
|
|
||||||
-- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
|
-- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결
|
||||||
-- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
|
-- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다.
|
||||||
""".formatted(owner.toLowerCase());
|
""".formatted(owner.toLowerCase(), dataCatalog.owner());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void appendSql(StringBuilder builder, String sql) {
|
private void appendSql(StringBuilder builder, String sql) {
|
||||||
|
|||||||
@@ -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<StructuredDataTable> objects();
|
||||||
|
StructuredDataTable require(String key);
|
||||||
|
}
|
||||||
@@ -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<StructuredDataTable> 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<StructuredDataTable> objects() { return objects; }
|
||||||
|
@Override public StructuredDataTable require(String key) {
|
||||||
|
return objects.stream().filter(item -> item.key().equals(key)).findFirst()
|
||||||
|
.orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StructuredDataTable> parse(String raw, ObjectMapper mapper) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<StructuredDataTable> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MaskingPolicyTarget> targets;
|
||||||
|
|
||||||
|
public EnvironmentMaskingPolicyCatalog(MaskingProperties properties, ObjectMapper objectMapper) {
|
||||||
|
targets = parse(properties.policies(), objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MaskingPolicyTarget> targets() {
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MaskingPolicyTarget> parse(String raw, ObjectMapper objectMapper) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<MaskingPolicyTarget> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||||
|
if (parsed.isEmpty()
|
||||||
|
|| parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
List<MaskingPolicyTarget> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface MaskingPolicyCatalog {
|
||||||
|
List<MaskingPolicyTarget> targets();
|
||||||
|
|
||||||
|
default Set<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,37 +23,31 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class MaskingPolicySynchronizer {
|
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 Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final MaskingRuleMapper mapper;
|
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.jdbcTemplate = jdbcTemplate;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
}
|
this.dataCatalog = dataCatalog;
|
||||||
|
this.policyCatalog = policyCatalog;
|
||||||
private static Map<String, String> managedPolicyMap() {
|
|
||||||
Map<String, String> 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> managedObjectNames() {
|
public Set<String> managedObjectNames() {
|
||||||
return MANAGED_POLICIES.keySet();
|
return policyCatalog.objectNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isManagedObject(String objectName) {
|
public boolean isManagedObject(String objectName) {
|
||||||
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
return policyCatalog.containsObject(objectName);
|
||||||
}
|
|
||||||
|
|
||||||
static String managedPolicyName(String objectName) {
|
|
||||||
return MANAGED_POLICIES.get(objectName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
public MaskingPolicySyncResult synchronize() {
|
public MaskingPolicySyncResult synchronize() {
|
||||||
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
||||||
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
||||||
if (OWNER.equalsIgnoreCase(rule.owner())
|
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
||||||
&& rule.ruleEnabled()
|
&& rule.ruleEnabled()
|
||||||
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
|
&& policyCatalog.containsObject(rule.objectName())) {
|
||||||
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
int addedColumns = 0;
|
int addedColumns = 0;
|
||||||
int modifiedColumns = 0;
|
int modifiedColumns = 0;
|
||||||
int droppedColumns = 0;
|
int droppedColumns = 0;
|
||||||
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
|
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
|
||||||
String objectName = policy.getKey();
|
String objectName = policy.objectName();
|
||||||
String policyName = policy.getValue();
|
String policyName = policy.policyName();
|
||||||
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
||||||
String enableStatus = policyEnableStatus(objectName, policyName);
|
String enableStatus = policyEnableStatus(objectName, policyName);
|
||||||
if (desired.isEmpty()) {
|
if (desired.isEmpty()) {
|
||||||
@@ -141,7 +134,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
SELECT enable
|
SELECT enable
|
||||||
FROM redaction_policies
|
FROM redaction_policies
|
||||||
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
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();
|
return statuses.isEmpty() ? null : statuses.getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM redaction_columns
|
FROM redaction_columns
|
||||||
WHERE object_owner = ? AND object_name = ?
|
WHERE object_owner = ? AND object_name = ?
|
||||||
""", String.class, OWNER, objectName).stream()
|
""", String.class, dataCatalog.owner(), objectName).stream()
|
||||||
.map(this::requiredColumnName)
|
.map(this::requiredColumnName)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
BEGIN
|
BEGIN
|
||||||
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, policyName);
|
""", dataCatalog.owner(), objectName, policyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void enablePolicy(String objectName, String policyName) {
|
private void enablePolicy(String objectName, String policyName) {
|
||||||
@@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
BEGIN
|
BEGIN
|
||||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, policyName);
|
""", dataCatalog.owner(), objectName, policyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void dropColumn(String objectName, String policyName, String columnName) {
|
private void dropColumn(String objectName, String policyName, String columnName) {
|
||||||
@@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, policyName, columnName);
|
""", dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addPolicy(
|
private void addPolicy(
|
||||||
@@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
END;
|
END;
|
||||||
""".formatted(functionConstant);
|
""".formatted(functionConstant);
|
||||||
if (regexPattern == null) {
|
if (regexPattern == null) {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
END;
|
END;
|
||||||
""".formatted(actionConstant, functionConstant);
|
""".formatted(actionConstant, functionConstant);
|
||||||
if (regexPattern == null) {
|
if (regexPattern == null) {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
} else {
|
} 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 => ?
|
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, columnName, expressionName);
|
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -28,19 +28,25 @@ public class MaskingRuleService {
|
|||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
|
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
|
||||||
|
private final DataCatalog dataCatalog;
|
||||||
|
private final MaskingPolicyCatalog maskingPolicyCatalog;
|
||||||
|
|
||||||
public MaskingRuleService(
|
public MaskingRuleService(
|
||||||
MaskingRuleMapper mapper,
|
MaskingRuleMapper mapper,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
ProtectedObjectService protectedObjectService,
|
ProtectedObjectService protectedObjectService,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
MaskingPolicySynchronizer maskingPolicySynchronizer
|
MaskingPolicySynchronizer maskingPolicySynchronizer,
|
||||||
|
DataCatalog dataCatalog,
|
||||||
|
MaskingPolicyCatalog maskingPolicyCatalog
|
||||||
) {
|
) {
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.protectedObjectService = protectedObjectService;
|
this.protectedObjectService = protectedObjectService;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
|
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
|
||||||
|
this.dataCatalog = dataCatalog;
|
||||||
|
this.maskingPolicyCatalog = maskingPolicyCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MaskingRule> findAllRules() {
|
public List<MaskingRule> findAllRules() {
|
||||||
@@ -55,9 +61,12 @@ public class MaskingRuleService {
|
|||||||
return mapper.findColumnRules();
|
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<MaskingPolicyStatus> findPolicyStatuses() {
|
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||||
return mapper.findPolicyStatuses();
|
if (maskingPolicyCatalog.targets().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> managedObjectNames() {
|
public Set<String> managedObjectNames() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -9,26 +10,27 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
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
|
@Service
|
||||||
public class McpSseService {
|
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 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 ObjectMapper objectMapper;
|
||||||
private final BackofficeProperties properties;
|
private final BackofficeProperties properties;
|
||||||
|
private final McpProperties mcpProperties;
|
||||||
|
|
||||||
public McpSseService(
|
public McpSseService(
|
||||||
SmilegateSelectAiService smilegateSelectAiService,
|
SelectAiService selectAiService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
BackofficeProperties properties
|
BackofficeProperties properties,
|
||||||
|
McpProperties mcpProperties
|
||||||
) {
|
) {
|
||||||
this.smilegateSelectAiService = smilegateSelectAiService;
|
this.selectAiService = selectAiService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.mcpProperties = mcpProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObjectNode handle(String contextPath, JsonNode request) {
|
public ObjectNode handle(String contextPath, JsonNode request) {
|
||||||
@@ -93,7 +95,7 @@ public class McpSseService {
|
|||||||
|
|
||||||
private ObjectNode selectAiVpdQueryTool() {
|
private ObjectNode selectAiVpdQueryTool() {
|
||||||
ObjectNode item = objectMapper.createObjectNode();
|
ObjectNode item = objectMapper.createObjectNode();
|
||||||
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
|
item.put("name", toolName());
|
||||||
item.put("description", selectAiVpdQueryView().description());
|
item.put("description", selectAiVpdQueryView().description());
|
||||||
|
|
||||||
ObjectNode schema = objectMapper.createObjectNode();
|
ObjectNode schema = objectMapper.createObjectNode();
|
||||||
@@ -102,7 +104,7 @@ public class McpSseService {
|
|||||||
|
|
||||||
ObjectNode prompt = objectMapper.createObjectNode();
|
ObjectNode prompt = objectMapper.createObjectNode();
|
||||||
prompt.put("type", "string");
|
prompt.put("type", "string");
|
||||||
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
|
prompt.put("description", promptDescription());
|
||||||
prompt.put("maxLength", 4000);
|
prompt.put("maxLength", 4000);
|
||||||
properties.set("prompt", prompt);
|
properties.set("prompt", prompt);
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ public class McpSseService {
|
|||||||
|
|
||||||
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
||||||
String toolName = params.path("name").asText("");
|
String toolName = params.path("name").asText("");
|
||||||
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
|
if (!toolName().equals(toolName)) {
|
||||||
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +130,13 @@ public class McpSseService {
|
|||||||
}
|
}
|
||||||
JsonNode response;
|
JsonNode response;
|
||||||
try {
|
try {
|
||||||
response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
|
response = selectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
|
||||||
} catch (VpdTokenAccessDeniedException ignored) {
|
} catch (VpdTokenAccessDeniedException ignored) {
|
||||||
return tokenAccessDeniedResult();
|
return tokenAccessDeniedResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
ObjectNode payload = objectMapper.createObjectNode();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
|
payload.put("toolName", toolName());
|
||||||
payload.put("profile", selectAiProfile());
|
payload.put("profile", selectAiProfile());
|
||||||
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
||||||
payload.set("response", response);
|
payload.set("response", response);
|
||||||
@@ -169,10 +171,10 @@ public class McpSseService {
|
|||||||
private McpToolView selectAiVpdQueryView() {
|
private McpToolView selectAiVpdQueryView() {
|
||||||
String profile = selectAiProfile();
|
String profile = selectAiProfile();
|
||||||
return new McpToolView(
|
return new McpToolView(
|
||||||
SELECT_AI_VPD_QUERY_TOOL,
|
toolName(),
|
||||||
profile + " 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.",
|
profile + " 프로파일로 " + toolDescription(),
|
||||||
-1L,
|
-1L,
|
||||||
"Smilegate 게임 데이터 Text2SQL",
|
toolLabel(),
|
||||||
SELECT_AI_VPD_QUERY_PATH
|
SELECT_AI_VPD_QUERY_PATH
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,11 +182,29 @@ public class McpSseService {
|
|||||||
private String selectAiProfile() {
|
private String selectAiProfile() {
|
||||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||||
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
||||||
return DEFAULT_SELECT_AI_PROFILE;
|
return "";
|
||||||
}
|
}
|
||||||
return selectAi.profile().trim();
|
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) {
|
private String pretty(Object value) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||||
|
|||||||
@@ -23,20 +23,21 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class SchemaMetadataService {
|
public class SchemaMetadataService {
|
||||||
|
|
||||||
private static final String OWNER = "SGMP_POC";
|
|
||||||
private static final int MAX_COMMENT_LENGTH = 4000;
|
private static final int MAX_COMMENT_LENGTH = 4000;
|
||||||
private static final int MAX_ANNOTATION_VALUE_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 static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
|
|
||||||
private final SchemaMetadataMapper mapper;
|
private final SchemaMetadataMapper mapper;
|
||||||
private final StructuredDataService structuredDataService;
|
private final StructuredDataService structuredDataService;
|
||||||
|
private final DataCatalog catalog;
|
||||||
|
|
||||||
public SchemaMetadataService(
|
public SchemaMetadataService(
|
||||||
SchemaMetadataMapper mapper,
|
SchemaMetadataMapper mapper,
|
||||||
StructuredDataService structuredDataService
|
StructuredDataService structuredDataService, DataCatalog catalog
|
||||||
) {
|
) {
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.structuredDataService = structuredDataService;
|
this.structuredDataService = structuredDataService;
|
||||||
|
this.catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
@@ -54,7 +55,7 @@ public class SchemaMetadataService {
|
|||||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||||
return new SchemaMetadataView(
|
return new SchemaMetadataView(
|
||||||
table,
|
table,
|
||||||
nullToEmpty(mapper.findTableComment(OWNER, tableName)),
|
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
|
||||||
annotations.getOrDefault(tableTargetKey(), List.of()),
|
annotations.getOrDefault(tableTargetKey(), List.of()),
|
||||||
columns
|
columns
|
||||||
);
|
);
|
||||||
@@ -65,7 +66,7 @@ public class SchemaMetadataService {
|
|||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||||
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
|
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -74,7 +75,7 @@ public class SchemaMetadataService {
|
|||||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
String column = requireColumn(tableName, columnName);
|
String column = requireColumn(tableName, columnName);
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||||
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
|
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -106,16 +107,16 @@ public class SchemaMetadataService {
|
|||||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||||
if (annotationExists(tableName, columnName, key)) {
|
if (annotationExists(tableName, columnName, key)) {
|
||||||
if (columnName == null) {
|
if (columnName == null) {
|
||||||
mapper.dropTableAnnotation(OWNER, tableName, key);
|
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
|
||||||
} else {
|
} else {
|
||||||
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
|
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!value.isBlank()) {
|
if (!value.isBlank()) {
|
||||||
if (columnName == null) {
|
if (columnName == null) {
|
||||||
mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
|
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
|
||||||
} else {
|
} 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,
|
String tableName,
|
||||||
Map<String, List<SchemaAnnotation>> annotations
|
Map<String, List<SchemaAnnotation>> annotations
|
||||||
) {
|
) {
|
||||||
return mapper.findColumns(OWNER, tableName).stream()
|
return mapper.findColumns(catalog.owner(), tableName).stream()
|
||||||
.map(row -> toColumn(row, annotations))
|
.map(row -> toColumn(row, annotations))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ public class SchemaMetadataService {
|
|||||||
|
|
||||||
private String requireColumn(String tableName, String columnName) {
|
private String requireColumn(String tableName, String columnName) {
|
||||||
String column = requireSimpleName(columnName, "column name");
|
String column = requireSimpleName(columnName, "column name");
|
||||||
if (mapper.countColumn(OWNER, tableName, column) == 0) {
|
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
|
||||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||||
}
|
}
|
||||||
return column;
|
return column;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
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.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -17,25 +21,18 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class SecuritySqlScriptService {
|
public class SecuritySqlScriptService {
|
||||||
|
|
||||||
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||||
new ScriptDefinition(
|
private static final Pattern RESOURCE_PATH = Pattern.compile(
|
||||||
"smilegate-tool-users",
|
"(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
|
||||||
"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 final List<ScriptDefinition> scripts;
|
||||||
|
|
||||||
|
public SecuritySqlScriptService(SecuritySqlScriptProperties properties, ObjectMapper objectMapper) {
|
||||||
|
scripts = parse(properties.scripts(), objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
public List<SecuritySqlScriptSummary> list() {
|
public List<SecuritySqlScriptSummary> list() {
|
||||||
return CURATED_SCRIPTS.stream()
|
return scripts.stream()
|
||||||
.map(definition -> new SecuritySqlScriptSummary(
|
.map(definition -> new SecuritySqlScriptSummary(
|
||||||
definition.scriptId(),
|
definition.scriptId(),
|
||||||
definition.category(),
|
definition.category(),
|
||||||
@@ -47,7 +44,7 @@ public class SecuritySqlScriptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SecuritySqlScript find(String scriptId) {
|
public SecuritySqlScript find(String scriptId) {
|
||||||
ScriptDefinition definition = CURATED_SCRIPTS.stream()
|
ScriptDefinition definition = scripts.stream()
|
||||||
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
||||||
@@ -70,7 +67,36 @@ public class SecuritySqlScriptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ScriptDefinition(
|
private List<ScriptDefinition> parse(String raw, ObjectMapper objectMapper) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<ScriptDefinition> 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 scriptId,
|
||||||
String category,
|
String category,
|
||||||
String fileName,
|
String fileName,
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import java.util.regex.Pattern;
|
|||||||
import org.springframework.stereotype.Service;
|
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
|
@Service
|
||||||
public class SmilegateSelectAiService {
|
public class SelectAiService {
|
||||||
|
|
||||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||||
private static final int MAX_RESULT_ROWS = 100;
|
private static final int MAX_RESULT_ROWS = 100;
|
||||||
@@ -40,7 +40,7 @@ public class SmilegateSelectAiService {
|
|||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public SmilegateSelectAiService(
|
public SelectAiService(
|
||||||
BackofficeProperties properties,
|
BackofficeProperties properties,
|
||||||
BearerTokenService bearerTokenService,
|
BearerTokenService bearerTokenService,
|
||||||
Clock clock,
|
Clock clock,
|
||||||
@@ -57,7 +57,7 @@ public class SmilegateSelectAiService {
|
|||||||
String normalizedPrompt = requiredPrompt(prompt);
|
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()) {
|
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_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ public class SmilegateSelectAiService {
|
|||||||
} catch (AppException exception) {
|
} catch (AppException exception) {
|
||||||
throw exception;
|
throw exception;
|
||||||
} catch (Exception 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();
|
connection.rollback();
|
||||||
}
|
}
|
||||||
} catch (Exception exception) {
|
} 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);
|
return new QueryExecution(items, truncated);
|
||||||
}
|
}
|
||||||
@@ -11,36 +11,25 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class StructuredDataService {
|
public class StructuredDataService {
|
||||||
|
|
||||||
private static final String OWNER = "SGMP_POC";
|
|
||||||
private static final int ROW_LIMIT = 50;
|
private static final int ROW_LIMIT = 50;
|
||||||
private static final List<StructuredDataTable> 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 JdbcTemplate jdbcTemplate;
|
||||||
|
private final DataCatalog catalog;
|
||||||
|
|
||||||
public StructuredDataService(JdbcTemplate jdbcTemplate) {
|
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
return TABLES;
|
return catalog.objects();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String defaultKey() {
|
public String defaultKey() {
|
||||||
return TABLES.getFirst().key();
|
return catalog.objects().getFirst().key();
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataTable requireTable(String key) {
|
public StructuredDataTable requireTable(String key) {
|
||||||
return TABLES.stream()
|
return catalog.require(key);
|
||||||
.filter(table -> table.key().equals(key))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataPreview preview(String key) {
|
public StructuredDataPreview preview(String key) {
|
||||||
@@ -54,7 +43,7 @@ public class StructuredDataService {
|
|||||||
AND table_name = ?
|
AND table_name = ?
|
||||||
ORDER BY column_id
|
ORDER BY column_id
|
||||||
""",
|
""",
|
||||||
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName());
|
(resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
|
||||||
if (columns.isEmpty()) {
|
if (columns.isEmpty()) {
|
||||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
@@ -63,24 +52,11 @@ public class StructuredDataService {
|
|||||||
previewSql(table), ROW_LIMIT);
|
previewSql(table), ROW_LIMIT);
|
||||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
||||||
} catch (DataAccessException exception) {
|
} 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) {
|
private String previewSql(StructuredDataTable table) {
|
||||||
return switch (table.key()) {
|
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
|
||||||
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("선택할 수 없는 정형 데이터 테이블입니다.");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class DashboardController {
|
|||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
public String dashboard(Model model) {
|
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.
|
// It intentionally does not query legacy CB_* VPD catalog objects.
|
||||||
model.addAttribute("users", userService.findAll());
|
model.addAttribute("users", userService.findAll());
|
||||||
model.addAttribute("groups", groupService.findAll());
|
model.addAttribute("groups", groupService.findAll());
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -114,6 +114,6 @@ public class SchemaMetadataController {
|
|||||||
private String readMessage(Exception exception) {
|
private String readMessage(Exception exception) {
|
||||||
return exception instanceof AppException
|
return exception instanceof AppException
|
||||||
? exception.getMessage()
|
? exception.getMessage()
|
||||||
: "DB 메타데이터를 조회하지 못했습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.";
|
: "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,9 +69,25 @@ backoffice:
|
|||||||
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
||||||
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
||||||
select-ai:
|
select-ai:
|
||||||
# Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
|
# Cloud AI profiles are schema-owned. This connection must use the profile owner's account,
|
||||||
# not the ADMIN connection used by the backoffice control plane.
|
# not the control-plane account used by the backoffice.
|
||||||
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
|
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
|
||||||
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
|
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
|
||||||
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
|
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:}
|
||||||
|
|||||||
@@ -68,10 +68,9 @@
|
|||||||
-->
|
-->
|
||||||
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
||||||
WITH managed_policy AS (
|
WITH managed_policy AS (
|
||||||
SELECT 'CZN_COMN_USER_MST' AS object_name, 'SG_CZN_USER_REDACT' AS policy_name FROM dual
|
<foreach collection="targets" item="target" separator=" UNION ALL ">
|
||||||
UNION ALL SELECT 'COMN_SALES_USER_MST', 'SG_SALES_USER_REDACT' FROM dual
|
SELECT #{target.objectName} AS object_name, #{target.policyName} AS policy_name FROM dual
|
||||||
UNION ALL SELECT 'COMN_SALES_TXN', 'SG_SALES_TXN_REDACT' FROM dual
|
</foreach>
|
||||||
UNION ALL SELECT 'COMN_REFUND_TXN', 'SG_REFUND_TXN_REDACT' FROM dual
|
|
||||||
),
|
),
|
||||||
configured AS (
|
configured AS (
|
||||||
SELECT protected_object.object_name,
|
SELECT protected_object.object_name,
|
||||||
@@ -80,7 +79,7 @@
|
|||||||
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
WHERE protected_object.owner = 'SGMP_POC'
|
WHERE protected_object.owner = #{owner}
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
GROUP BY protected_object.object_name
|
GROUP BY protected_object.object_name
|
||||||
),
|
),
|
||||||
@@ -96,7 +95,7 @@
|
|||||||
LEFT JOIN redaction_columns policy_column
|
LEFT JOIN redaction_columns policy_column
|
||||||
ON policy_column.object_owner = policy.object_owner
|
ON policy_column.object_owner = policy.object_owner
|
||||||
AND policy_column.object_name = policy.object_name
|
AND policy_column.object_name = policy.object_name
|
||||||
WHERE policy.object_owner = 'SGMP_POC'
|
WHERE policy.object_owner = #{owner}
|
||||||
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
||||||
),
|
),
|
||||||
missing_columns AS (
|
missing_columns AS (
|
||||||
@@ -110,7 +109,7 @@
|
|||||||
ON policy_column.object_owner = protected_object.owner
|
ON policy_column.object_owner = protected_object.owner
|
||||||
AND policy_column.object_name = protected_object.object_name
|
AND policy_column.object_name = protected_object.object_name
|
||||||
AND policy_column.column_name = protected_column.column_name
|
AND policy_column.column_name = protected_column.column_name
|
||||||
WHERE protected_object.owner = 'SGMP_POC'
|
WHERE protected_object.owner = #{owner}
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
AND policy_column.column_name IS NULL
|
AND policy_column.column_name IS NULL
|
||||||
GROUP BY protected_object.object_name
|
GROUP BY protected_object.object_name
|
||||||
@@ -120,14 +119,14 @@
|
|||||||
COUNT(*) AS extra_column_count
|
COUNT(*) AS extra_column_count
|
||||||
FROM redaction_columns policy_column
|
FROM redaction_columns policy_column
|
||||||
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
||||||
WHERE policy_column.object_owner = 'SGMP_POC'
|
WHERE policy_column.object_owner = #{owner}
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM sg_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
WHERE protected_object.owner = 'SGMP_POC'
|
WHERE protected_object.owner = #{owner}
|
||||||
AND protected_object.object_name = policy_column.object_name
|
AND protected_object.object_name = policy_column.object_name
|
||||||
AND protected_column.column_name = policy_column.column_name
|
AND protected_column.column_name = policy_column.column_name
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
@@ -138,15 +137,14 @@
|
|||||||
SELECT policy.object_name,
|
SELECT policy.object_name,
|
||||||
COUNT(*) AS legacy_vpd_column_policy_count
|
COUNT(*) AS legacy_vpd_column_policy_count
|
||||||
FROM all_policies policy
|
FROM all_policies policy
|
||||||
WHERE policy.object_owner = 'SGMP_POC'
|
JOIN managed_policy managed
|
||||||
|
ON managed.object_name = policy.object_name
|
||||||
|
AND managed.policy_name = policy.policy_name
|
||||||
|
WHERE policy.object_owner = #{owner}
|
||||||
AND policy.enable = 'YES'
|
AND policy.enable = 'YES'
|
||||||
AND policy.policy_name IN (
|
|
||||||
'SG_CZN_USER_REDACT', 'SG_SALES_USER_REDACT',
|
|
||||||
'SG_SALES_TXN_REDACT', 'SG_REFUND_TXN_REDACT'
|
|
||||||
)
|
|
||||||
GROUP BY policy.object_name
|
GROUP BY policy.object_name
|
||||||
)
|
)
|
||||||
SELECT 'SGMP_POC' AS owner,
|
SELECT #{owner} AS owner,
|
||||||
managed.object_name,
|
managed.object_name,
|
||||||
managed.policy_name,
|
managed.policy_name,
|
||||||
database_policy.enable AS enabled,
|
database_policy.enable AS enabled,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="backoffice-can-mutate" th:content="${canMutate}">
|
<meta name="backoffice-can-mutate" th:content="${canMutate}">
|
||||||
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
|
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
|
||||||
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
|
<title th:text="${title == (product?.pageTitle() ?: 'Data & AI Backoffice') ? title : title + ' · ' + (product?.pageTitle() ?: 'Data & AI Backoffice')}">Data & AI Backoffice</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/css/app.css" rel="stylesheet">
|
<link href="/css/app.css" rel="stylesheet">
|
||||||
<!-- External UI helpers must not block initial HTML rendering. In
|
<!-- External UI helpers must not block initial HTML rendering. In
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
|
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
|
||||||
<div class="container rw-nav-top">
|
<div class="container rw-nav-top">
|
||||||
<a class="navbar-brand" href="/">SMILEGATE DATA & AI POC</a>
|
<a class="navbar-brand" href="/" th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data & AI Backoffice</a>
|
||||||
<div class="rw-menu" aria-label="주요 메뉴">
|
<div class="rw-menu" aria-label="주요 메뉴">
|
||||||
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
|
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
|
||||||
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
|
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
|
||||||
|
|||||||
@@ -108,8 +108,8 @@
|
|||||||
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${policyStatuses}">
|
<tr th:each="status : ${policyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
|
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${status.policyName()}">SG_GAME_USER_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">0</td>
|
<td th:text="${status.appliedColumnCount()}">0</td>
|
||||||
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
|
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
|
||||||
@@ -209,11 +209,11 @@
|
|||||||
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
||||||
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
||||||
th:value="|${object.objectId()}:${columnName}|"
|
th:value="|${object.objectId()}:${columnName}|"
|
||||||
th:text="${object.displayName() + '.' + columnName}">SGMP_POC.COMN_SALES_TXN.GUID</option>
|
th:text="${object.displayName() + '.' + columnName}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</th:block>
|
</th:block>
|
||||||
</select>
|
</select>
|
||||||
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 게임 데이터 객체만 표시됩니다. 예: <code>SGMP_POC.COMN_SALES_TXN.GUID</code>.</span>
|
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 등록 업무 데이터 객체만 표시됩니다. 예: <code>OWNER.OBJECT_NAME.COLUMN_NAME</code>.</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
|
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="columnRule : ${columnRules}">
|
<tr th:each="columnRule : ${columnRules}">
|
||||||
<td><code th:text="${columnRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.USER_ID</code></td>
|
<td><code th:text="${columnRule.targetLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</code></td>
|
||||||
<td th:text="${columnRule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
<td th:text="${columnRule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
||||||
<td th:text="${columnRule.template().label()}">식별자 부분 마스킹</td>
|
<td th:text="${columnRule.template().label()}">식별자 부분 마스킹</td>
|
||||||
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
|
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>MCP 연동</h1>
|
<h1>MCP 연동</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>사용자 Bearer Token을 검증한 뒤 <code>SGMP_POC_OCI_GPT54MINI</code> Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.</p>
|
<p>사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일로 업무 데이터 Text2SQL을 생성하는 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
HTTP <code>Authorization</code> → 활성 PoC 사용자 토큰 검증 · <code>prompt</code> → 게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
|
HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · <code>prompt</code> → 업무 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -116,9 +116,9 @@
|
|||||||
<summary>tools/call parameter 예시 보기</summary>
|
<summary>tools/call parameter 예시 보기</summary>
|
||||||
<h2>tools/call Arguments</h2>
|
<h2>tools/call Arguments</h2>
|
||||||
<pre class="code-block">{
|
<pre class="code-block">{
|
||||||
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
|
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
|
||||||
}</pre>
|
}</pre>
|
||||||
<p class="form-hint">등록 tool은 <code>oracle.select_ai.smilegate_game_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 <code>SGMP_POC_OCI_GPT54MINI</code>가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
|
<p class="form-hint">등록 tool은 <code th:text="${mcp?.resolvedToolName() ?: 'oracle.select_ai.data_text2sql'}">oracle.select_ai.data_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 Select AI가 comment, annotation, constraint를 참고해 읽기 전용 업무 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -152,9 +152,9 @@
|
|||||||
"id": 3,
|
"id": 3,
|
||||||
"method": "tools/call",
|
"method": "tools/call",
|
||||||
"params": {
|
"params": {
|
||||||
"name": "oracle.select_ai.smilegate_game_text2sql",
|
"name": "oracle.select_ai.data_text2sql",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
|
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}</pre>
|
}</pre>
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${maskingPolicyStatuses}">
|
<tr th:each="status : ${maskingPolicyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">SGMP_POC.COMN_SALES_TXN</code></td>
|
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${status.policyName()}">SG_SALES_TXN_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">0</td>
|
<td th:text="${status.appliedColumnCount()}">0</td>
|
||||||
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
|
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
|
||||||
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
|
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
|
||||||
<td><code th:text="${item.fileName()}">71_sg_identity_administration.sql</code></td>
|
<td><code th:text="${item.fileName()}">category/script.sql</code></td>
|
||||||
<td>
|
<td>
|
||||||
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
|
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
|
||||||
<div class="form-hint" th:text="${item.description()}">설명</div>
|
<div class="form-hint" th:text="${item.description()}">설명</div>
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
<section class="content-band" th:if="${selectedScript}">
|
<section class="content-band" th:if="${selectedScript}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">Smilegate 권한</span>
|
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">권한</span>
|
||||||
<h2 class="mt-2" th:text="${selectedScript.title()}">사용자·그룹·역할 관리 모델</h2>
|
<h2 class="mt-2" th:text="${selectedScript.title()}">사용자·그룹·역할 관리 모델</h2>
|
||||||
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
|
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
|
||||||
</div>
|
</div>
|
||||||
<code th:text="${selectedScript.fileName()}">71_sg_identity_administration.sql</code>
|
<code th:text="${selectedScript.fileName()}">category/script.sql</code>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/71_sg_identity_administration.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
|
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/category/script.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
|
||||||
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
|
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
|
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
|
||||||
|
|||||||
@@ -8,19 +8,19 @@
|
|||||||
<h1>정형 데이터 조회</h1>
|
<h1>정형 데이터 조회</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>스마일게이트 게임 데이터 테이블을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
|
<p>등록된 업무 데이터 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
이 화면은 관리자용 게임 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
이 화면은 관리자용 업무 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>조회할 게임 데이터 선택</h2>
|
<h2>조회할 업무 데이터 선택</h2>
|
||||||
<p class="section-subtitle"><code>SGMP_POC</code> 스키마에서 PoC 대상으로 등록한 게임 데이터 테이블만 표시합니다.</p>
|
<p class="section-subtitle"><code th:text="${catalogOwner}">OWNER</code> 스키마에서 등록한 <span th:text="${product.dataName()}">업무 데이터</span> 객체만 표시합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="structured-table-grid">
|
<div class="structured-table-grid">
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/structured-data(table=${entry.key()})}">
|
th:href="@{/structured-data(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
||||||
<code th:text="${entry.tableName()}">CZN_COMN_USER_MST</code>
|
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
|
||||||
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 th:text="${preview.table().businessName()}">게임 사용자</h2>
|
<h2 th:text="${preview.table().businessName()}">게임 사용자</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
<code th:text="${'SGMP_POC.' + preview.table().tableName()}">SGMP_POC.CZN_COMN_USER_MST</code>
|
<code th:text="${catalogOwner + '.' + preview.table().tableName()}">OWNER.TABLE_NAME</code>
|
||||||
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head th:replace="~{fragments/layout :: head('Smilegate 액세스 토큰')}"></head>
|
<head th:replace="~{fragments/layout :: head('액세스 토큰')}"></head>
|
||||||
<body>
|
<body>
|
||||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>Smilegate 액세스 토큰</h1>
|
<h1>액세스 토큰</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
<p>Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
data-result=${columnRule.template().previewResult()},
|
data-result=${columnRule.template().previewResult()},
|
||||||
data-aso-function=${columnRule.template().asoFunction()},
|
data-aso-function=${columnRule.template().asoFunction()},
|
||||||
data-context=${'MR_' + columnRule.columnId()}"
|
data-context=${'MR_' + columnRule.columnId()}"
|
||||||
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</option>
|
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="userRule : ${userRules}">
|
<tr th:each="userRule : ${userRules}">
|
||||||
<td th:text="${userRule.username()}">sg-teamlead</td>
|
<td th:text="${userRule.username()}">sg-teamlead</td>
|
||||||
<td><code th:text="${userRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</code></td>
|
<td><code th:text="${userRule.targetLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</code></td>
|
||||||
<td th:text="${userRule.ruleLabel()}">게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹</td>
|
<td th:text="${userRule.ruleLabel()}">게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹</td>
|
||||||
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
|
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
|
||||||
<td th:text="${userRule.activeYn()}">Y</td>
|
<td th:text="${userRule.activeYn()}">Y</td>
|
||||||
|
|||||||
@@ -82,8 +82,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="policy : ${policies}">
|
<tr th:each="policy : ${policies}">
|
||||||
<td><code th:text="${policy.objectDisplayName()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
|
<td><code th:text="${policy.objectDisplayName()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${policy.policyName()}">SG_CZN_USER_ROW_POLICY</code></td>
|
<td><code th:text="${policy.policyName()}">ROW_ACCESS_POLICY</code></td>
|
||||||
<td th:text="${policy.statementTypes()}">SELECT</td>
|
<td th:text="${policy.statementTypes()}">SELECT</td>
|
||||||
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
|
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
|
||||||
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
|
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ class MaskingPolicySynchronizerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void smilegateSalesTransactionPolicyIsManagedByBackoffice() {
|
void redactionPolicyManagementIsNotBoundToAProductSpecificMapping() {
|
||||||
assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN"))
|
assertThat(MaskingPolicySynchronizer.class.getDeclaredFields())
|
||||||
.isEqualTo("SG_SALES_TXN_REDACT");
|
.extracting(field -> field.getName())
|
||||||
|
.doesNotContain("MANAGED_POLICIES", "OWNER");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
@@ -10,7 +11,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
class McpSseServiceTest {
|
class McpSseServiceTest {
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService();
|
private final SelectAiService selectAiService = new CapturingSelectAiService();
|
||||||
private final McpSseService service = new McpSseService(
|
private final McpSseService service = new McpSseService(
|
||||||
selectAiService,
|
selectAiService,
|
||||||
objectMapper,
|
objectMapper,
|
||||||
@@ -20,6 +21,12 @@ class McpSseServiceTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
new BackofficeProperties.SelectAi("", "", "", "SGMP_POC_OCI_GPT54MINI")
|
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");
|
var tools = response.path("result").path("tools");
|
||||||
assertThat(tools).hasSize(1);
|
assertThat(tools).hasSize(1);
|
||||||
var selectAi = tools.get(0);
|
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("description").asText()).contains("SGMP_POC_OCI_GPT54MINI");
|
||||||
assertThat(selectAi.path("inputSchema").path("required"))
|
assertThat(selectAi.path("inputSchema").path("required"))
|
||||||
.extracting(node -> node.asText())
|
.extracting(node -> node.asText())
|
||||||
@@ -44,14 +51,13 @@ class McpSseServiceTest {
|
|||||||
void callsVpdSelectAiThroughOrdsService() {
|
void callsVpdSelectAiThroughOrdsService() {
|
||||||
ObjectNode request = request(2, "tools/call");
|
ObjectNode request = request(2, "tools/call");
|
||||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
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");
|
ObjectNode arguments = params.putObject("arguments");
|
||||||
arguments.put("prompt", "카제나 AU를 조회해 줘");
|
arguments.put("prompt", "카제나 AU를 조회해 줘");
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request, "user-bearer");
|
ObjectNode response = service.handle("default", request, "user-bearer");
|
||||||
|
|
||||||
CapturingSmilegateSelectAiService agentService =
|
CapturingSelectAiService agentService = (CapturingSelectAiService) selectAiService;
|
||||||
(CapturingSmilegateSelectAiService) selectAiService;
|
|
||||||
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
||||||
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
|
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
|
||||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||||
@@ -69,7 +75,7 @@ class McpSseServiceTest {
|
|||||||
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
|
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
|
||||||
ObjectNode request = request(3, "tools/call");
|
ObjectNode request = request(3, "tools/call");
|
||||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
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를 조회해 줘");
|
params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘");
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request, "");
|
ObjectNode response = service.handle("default", request, "");
|
||||||
@@ -89,12 +95,12 @@ class McpSseServiceTest {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService {
|
private static final class CapturingSelectAiService extends SelectAiService {
|
||||||
|
|
||||||
private String bearerToken;
|
private String bearerToken;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
|
|
||||||
private CapturingSmilegateSelectAiService() {
|
private CapturingSelectAiService() {
|
||||||
super(null, null, null, new ObjectMapper());
|
super(null, null, null, new ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ class SchemaMetadataServiceTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
mapper = mock(SchemaMetadataMapper.class);
|
mapper = mock(SchemaMetadataMapper.class);
|
||||||
StructuredDataService structuredDataService = mock(StructuredDataService.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.requireTable("game-users")).thenReturn(GAME_USERS);
|
||||||
when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS));
|
when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS));
|
||||||
when(structuredDataService.defaultKey()).thenReturn("game-users");
|
when(structuredDataService.defaultKey()).thenReturn("game-users");
|
||||||
service = new SchemaMetadataService(mapper, structuredDataService);
|
service = new SchemaMetadataService(mapper, structuredDataService, catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -3,22 +3,31 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
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;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class SecuritySqlScriptServiceTest {
|
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
|
@Test
|
||||||
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
|
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
|
||||||
assertThat(service.list())
|
assertThat(service.list())
|
||||||
.extracting(item -> item.fileName())
|
.extracting(item -> item.fileName())
|
||||||
.containsExactly(
|
.containsExactly(
|
||||||
"70_sg_tool_user.sql",
|
"legacy/smilegate/70_tool_user.sql", "legacy/smilegate/71_identity_administration.sql"
|
||||||
"71_sg_identity_administration.sql"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(service.find("smilegate-identity-administration").source())
|
assertThat(service.find("identity-administration").source())
|
||||||
.contains("create table sg_app_user")
|
.contains("create table sg_app_user")
|
||||||
.contains("DATA_AI_TF");
|
.contains("DATA_AI_TF");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,29 +6,30 @@ import static org.mockito.Mockito.mock;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import java.util.List;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
|
||||||
class StructuredDataServiceTest {
|
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
|
@Test
|
||||||
void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
|
void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
|
||||||
|
when(catalog.objects()).thenReturn(List.of(
|
||||||
|
new com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래")));
|
||||||
assertThat(service.tables())
|
assertThat(service.tables())
|
||||||
.extracting(table -> table.tableName())
|
.extracting(table -> table.tableName())
|
||||||
.containsExactly(
|
.containsExactly("COMN_SALES_TXN");
|
||||||
"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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsAnyTableOutsideTheServerSideAllowlist() {
|
void rejectsAnyTableOutsideTheServerSideAllowlist() {
|
||||||
|
doThrow(new AppException("선택할 수 없는 카탈로그 객체입니다."))
|
||||||
|
.when(catalog).require("security-audit-log");
|
||||||
assertThatThrownBy(() -> service.requireTable("security-audit-log"))
|
assertThatThrownBy(() -> service.requireTable("security-audit-log"))
|
||||||
.isInstanceOf(AppException.class)
|
.isInstanceOf(AppException.class)
|
||||||
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
|
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user