refs #722: externalize backoffice customer configuration

This commit is contained in:
devmrko
2026-07-23 19:14:37 +09:00
parent 7ba173240c
commit 53342e7bc3
47 changed files with 622 additions and 216 deletions

View File

@@ -51,12 +51,28 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD=""
# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다.
# Select AI는 프로파일 소유 스키마로 별도 접속합니다.
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_DB_USERNAME="SGMP_POC"
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_OCI_GPT54MINI"
export BACKOFFICE_SELECT_AI_PROFILE=""
# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"customers","tableName":"CUSTOMER","objectType":"TABLE","businessName":"고객","description":"고객 기본 정보"}]'
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다.
export BACKOFFICE_MASKING_POLICIES=''
# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다.
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false"

View 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를 자동으로 생성·변경하지 않는다.

View File

@@ -92,15 +92,12 @@
<resource>
<directory>src/main/resources</directory>
</resource>
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
only the curated read-only viewer set into the deployed JAR. -->
<!-- Keep reviewed SQL in sql/adb. Runtime exposure remains restricted by
BACKOFFICE_SECURITY_SQL_SCRIPTS, never by a request path. -->
<resource>
<directory>sql/adb</directory>
<targetPath>sql/adb</targetPath>
<includes>
<include>70_sg_tool_user.sql</include>
<include>71_sg_identity_administration.sql</include>
</includes>
<includes><include>**/*.sql</include></includes>
</resource>
</resources>
<plugins>

View File

@@ -6,7 +6,14 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(BackofficeProperties.class)
@EnableConfigurationProperties({
BackofficeProperties.class,
CatalogProperties.class,
MaskingProperties.class,
McpProperties.class,
ProductProperties.class,
SecuritySqlScriptProperties.class
})
public class AppConfig {
@Bean

View File

@@ -74,7 +74,7 @@ public record BackofficeProperties(
}
}
/** Separate ADB connection because Select AI profiles are owned by SGMP_POC. */
/** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
public record SelectAi(
String dbUrl,
String dbUsername,

View File

@@ -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) {
}

View File

@@ -51,6 +51,6 @@ public class DbPoolWarmup {
groupService.findGroupRoles();
permissionService.findRoles();
permissionService.findPermissionViews();
log.info("Smilegate identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
log.info("Identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
}
}

View File

@@ -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) {
}

View File

@@ -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();
}
}

View File

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

View File

@@ -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) {
}

View File

@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.domain.structured;
public record StructuredDataTable(
String key,
String tableName,
String objectType,
String businessName,
String description
) {
public StructuredDataTable(String key, String tableName, String businessName, String description) {
this(key, tableName, "TABLE", businessName, description);
}
}

View File

@@ -5,6 +5,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -28,7 +29,10 @@ public interface MaskingRuleMapper {
List<ColumnMaskingRule> findColumnRules();
List<MaskingPolicyStatus> findPolicyStatuses();
List<MaskingPolicyStatus> findPolicyStatuses(
@Param("owner") String owner,
@Param("targets") List<MaskingPolicyTarget> targets
);
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);

View File

@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
private final JdbcTemplate jdbcTemplate;
private final BackofficeProperties properties;
private final DataCatalog dataCatalog;
public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) {
public BackofficeSchemaService(
JdbcTemplate jdbcTemplate,
BackofficeProperties properties,
DataCatalog dataCatalog
) {
this.jdbcTemplate = jdbcTemplate;
this.properties = properties;
this.dataCatalog = dataCatalog;
}
public SchemaPreflightView preflight() {
@@ -705,7 +711,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
@sql/adb/71_sg_identity_administration.sql
-- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
@@ -717,9 +723,9 @@ public class BackofficeSchemaService {
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
-- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
-- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
""".formatted(owner.toLowerCase());
-- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결
-- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다.
""".formatted(owner.toLowerCase(), dataCatalog.owner());
}
private void appendSql(StringBuilder builder, String sql) {

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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));
}
}

View File

@@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -24,37 +23,31 @@ import org.springframework.stereotype.Service;
@Service
public class MaskingPolicySynchronizer {
private static final String OWNER = "SGMP_POC";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
private final JdbcTemplate jdbcTemplate;
private final MaskingRuleMapper mapper;
private final DataCatalog dataCatalog;
private final MaskingPolicyCatalog policyCatalog;
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
public MaskingPolicySynchronizer(
JdbcTemplate jdbcTemplate,
MaskingRuleMapper mapper,
DataCatalog dataCatalog,
MaskingPolicyCatalog policyCatalog
) {
this.jdbcTemplate = jdbcTemplate;
this.mapper = mapper;
}
private static Map<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);
this.dataCatalog = dataCatalog;
this.policyCatalog = policyCatalog;
}
public Set<String> managedObjectNames() {
return MANAGED_POLICIES.keySet();
return policyCatalog.objectNames();
}
public boolean isManagedObject(String objectName) {
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
}
static String managedPolicyName(String objectName) {
return MANAGED_POLICIES.get(objectName);
return policyCatalog.containsObject(objectName);
}
/**
@@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer {
public MaskingPolicySyncResult synchronize() {
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
if (OWNER.equalsIgnoreCase(rule.owner())
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
&& rule.ruleEnabled()
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
&& policyCatalog.containsObject(rule.objectName())) {
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
}
}
@@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer {
int addedColumns = 0;
int modifiedColumns = 0;
int droppedColumns = 0;
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
String objectName = policy.getKey();
String policyName = policy.getValue();
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
String objectName = policy.objectName();
String policyName = policy.policyName();
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
String enableStatus = policyEnableStatus(objectName, policyName);
if (desired.isEmpty()) {
@@ -141,7 +134,7 @@ public class MaskingPolicySynchronizer {
SELECT enable
FROM redaction_policies
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
""", String.class, OWNER, objectName, policyName);
""", String.class, dataCatalog.owner(), objectName, policyName);
return statuses.isEmpty() ? null : statuses.getFirst();
}
@@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer {
SELECT column_name
FROM redaction_columns
WHERE object_owner = ? AND object_name = ?
""", String.class, OWNER, objectName).stream()
""", String.class, dataCatalog.owner(), objectName).stream()
.map(this::requiredColumnName)
.toList();
}
@@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer {
BEGIN
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END;
""", OWNER, objectName, policyName);
""", dataCatalog.owner(), objectName, policyName);
}
private void enablePolicy(String objectName, String policyName) {
@@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer {
BEGIN
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END;
""", OWNER, objectName, policyName);
""", dataCatalog.owner(), objectName, policyName);
}
private void dropColumn(String objectName, String policyName, String columnName) {
@@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer {
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
);
END;
""", OWNER, objectName, policyName, columnName);
""", dataCatalog.owner(), objectName, policyName, columnName);
}
private void addPolicy(
@@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer {
END;
""".formatted(functionConstant);
if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
} else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
}
return;
}
@@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer {
END;
""".formatted(actionConstant, functionConstant);
if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
} else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
}
}
@@ -314,7 +307,7 @@ public class MaskingPolicySynchronizer {
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
);
END;
""", OWNER, objectName, columnName, expressionName);
""", dataCatalog.owner(), objectName, columnName, expressionName);
}
}

View File

@@ -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) {
}

View File

@@ -28,19 +28,25 @@ public class MaskingRuleService {
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
private final DataCatalog dataCatalog;
private final MaskingPolicyCatalog maskingPolicyCatalog;
public MaskingRuleService(
MaskingRuleMapper mapper,
UserMapper userMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService,
MaskingPolicySynchronizer maskingPolicySynchronizer
MaskingPolicySynchronizer maskingPolicySynchronizer,
DataCatalog dataCatalog,
MaskingPolicyCatalog maskingPolicyCatalog
) {
this.mapper = mapper;
this.userMapper = userMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
this.dataCatalog = dataCatalog;
this.maskingPolicyCatalog = maskingPolicyCatalog;
}
public List<MaskingRule> findAllRules() {
@@ -55,9 +61,12 @@ public class MaskingRuleService {
return mapper.findColumnRules();
}
/** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
/** Reads the actual Oracle Data Redaction state for configured managed objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses();
if (maskingPolicyCatalog.targets().isEmpty()) {
return List.of();
}
return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets());
}
public Set<String> managedObjectNames() {

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -9,26 +10,27 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
/** MCP boundary exposing the Smilegate game-data Select AI generation and read-only execution tool. */
/** MCP boundary exposing a configured Select AI generation and read-only execution tool. */
@Service
public class McpSseService {
private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
private static final String DEFAULT_SELECT_AI_PROFILE = "SGMP_POC_OCI_GPT54MINI";
private final SmilegateSelectAiService smilegateSelectAiService;
private final SelectAiService selectAiService;
private final ObjectMapper objectMapper;
private final BackofficeProperties properties;
private final McpProperties mcpProperties;
public McpSseService(
SmilegateSelectAiService smilegateSelectAiService,
SelectAiService selectAiService,
ObjectMapper objectMapper,
BackofficeProperties properties
BackofficeProperties properties,
McpProperties mcpProperties
) {
this.smilegateSelectAiService = smilegateSelectAiService;
this.selectAiService = selectAiService;
this.objectMapper = objectMapper;
this.properties = properties;
this.mcpProperties = mcpProperties;
}
public ObjectNode handle(String contextPath, JsonNode request) {
@@ -93,7 +95,7 @@ public class McpSseService {
private ObjectNode selectAiVpdQueryTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
item.put("name", toolName());
item.put("description", selectAiVpdQueryView().description());
ObjectNode schema = objectMapper.createObjectNode();
@@ -102,7 +104,7 @@ public class McpSseService {
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("description", promptDescription());
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
@@ -117,7 +119,7 @@ public class McpSseService {
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String toolName = params.path("name").asText("");
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
if (!toolName().equals(toolName)) {
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
}
@@ -128,13 +130,13 @@ public class McpSseService {
}
JsonNode response;
try {
response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
response = selectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
payload.put("toolName", toolName());
payload.put("profile", selectAiProfile());
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
payload.set("response", response);
@@ -169,10 +171,10 @@ public class McpSseService {
private McpToolView selectAiVpdQueryView() {
String profile = selectAiProfile();
return new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
profile + " 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.",
toolName(),
profile + " 프로파일로 " + toolDescription(),
-1L,
"Smilegate 게임 데이터 Text2SQL",
toolLabel(),
SELECT_AI_VPD_QUERY_PATH
);
}
@@ -180,11 +182,29 @@ public class McpSseService {
private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
return DEFAULT_SELECT_AI_PROFILE;
return "";
}
return selectAi.profile().trim();
}
private String toolName() {
return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName();
}
private String toolLabel() {
return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel();
}
private String toolDescription() {
return mcpProperties == null
? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다."
: mcpProperties.resolvedToolDescription();
}
private String promptDescription() {
return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription();
}
private String pretty(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);

View File

@@ -23,20 +23,21 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class SchemaMetadataService {
private static final String OWNER = "SGMP_POC";
private static final int MAX_COMMENT_LENGTH = 4000;
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final SchemaMetadataMapper mapper;
private final StructuredDataService structuredDataService;
private final DataCatalog catalog;
public SchemaMetadataService(
SchemaMetadataMapper mapper,
StructuredDataService structuredDataService
StructuredDataService structuredDataService, DataCatalog catalog
) {
this.mapper = mapper;
this.structuredDataService = structuredDataService;
this.catalog = catalog;
}
public List<StructuredDataTable> tables() {
@@ -54,7 +55,7 @@ public class SchemaMetadataService {
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView(
table,
nullToEmpty(mapper.findTableComment(OWNER, tableName)),
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
annotations.getOrDefault(tableTargetKey(), List.of()),
columns
);
@@ -65,7 +66,7 @@ public class SchemaMetadataService {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name");
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
}
@Transactional
@@ -74,7 +75,7 @@ public class SchemaMetadataService {
String tableName = requireSimpleName(table.tableName(), "table name");
String column = requireColumn(tableName, columnName);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
}
@Transactional
@@ -106,16 +107,16 @@ public class SchemaMetadataService {
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
if (annotationExists(tableName, columnName, key)) {
if (columnName == null) {
mapper.dropTableAnnotation(OWNER, tableName, key);
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
} else {
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
}
}
if (!value.isBlank()) {
if (columnName == null) {
mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
} else {
mapper.addColumnAnnotation(OWNER, tableName, columnName, key, quoteLiteral(value));
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
}
}
}
@@ -124,7 +125,7 @@ public class SchemaMetadataService {
String tableName,
Map<String, List<SchemaAnnotation>> annotations
) {
return mapper.findColumns(OWNER, tableName).stream()
return mapper.findColumns(catalog.owner(), tableName).stream()
.map(row -> toColumn(row, annotations))
.toList();
}
@@ -175,7 +176,7 @@ public class SchemaMetadataService {
private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name");
if (mapper.countColumn(OWNER, tableName, column) == 0) {
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
return column;

View File

@@ -1,11 +1,15 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
@@ -17,25 +21,18 @@ import org.springframework.stereotype.Service;
@Service
public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
new ScriptDefinition(
"smilegate-tool-users",
"Smilegate 사용자",
"70_sg_tool_user.sql",
"PoC 도구 사용자 초기 데이터",
"Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
),
new ScriptDefinition(
"smilegate-identity-administration",
"Smilegate 권한",
"71_sg_identity_administration.sql",
"사용자·그룹·역할 관리 모델",
"Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
)
private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
private static final Pattern RESOURCE_PATH = Pattern.compile(
"(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
);
private final List<ScriptDefinition> scripts;
public SecuritySqlScriptService(SecuritySqlScriptProperties properties, ObjectMapper objectMapper) {
scripts = parse(properties.scripts(), objectMapper);
}
public List<SecuritySqlScriptSummary> list() {
return CURATED_SCRIPTS.stream()
return scripts.stream()
.map(definition -> new SecuritySqlScriptSummary(
definition.scriptId(),
definition.category(),
@@ -47,7 +44,7 @@ public class SecuritySqlScriptService {
}
public SecuritySqlScript find(String scriptId) {
ScriptDefinition definition = CURATED_SCRIPTS.stream()
ScriptDefinition definition = scripts.stream()
.filter(candidate -> candidate.scriptId().equals(scriptId))
.findFirst()
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
@@ -70,7 +67,36 @@ public class SecuritySqlScriptService {
}
}
private record ScriptDefinition(
private List<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 category,
String fileName,

View File

@@ -21,10 +21,10 @@ import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile.
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
*/
@Service
public class SmilegateSelectAiService {
public class SelectAiService {
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_RESULT_ROWS = 100;
@@ -40,7 +40,7 @@ public class SmilegateSelectAiService {
private final Clock clock;
private final ObjectMapper objectMapper;
public SmilegateSelectAiService(
public SelectAiService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock,
@@ -57,7 +57,7 @@ public class SmilegateSelectAiService {
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
throw new AppException("Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
@@ -117,7 +117,7 @@ public class SmilegateSelectAiService {
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage());
}
}
@@ -155,7 +155,7 @@ public class SmilegateSelectAiService {
connection.rollback();
}
} catch (Exception exception) {
throw new AppException("Smilegate Select AI 생성 SQL 실행 실패: " + exception.getMessage());
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
}
return new QueryExecution(items, truncated);
}

View File

@@ -11,36 +11,25 @@ import org.springframework.stereotype.Service;
@Service
public class StructuredDataService {
private static final String OWNER = "SGMP_POC";
private static final int ROW_LIMIT = 50;
private static final List<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 DataCatalog catalog;
public StructuredDataService(JdbcTemplate jdbcTemplate) {
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
this.jdbcTemplate = jdbcTemplate;
this.catalog = catalog;
}
public List<StructuredDataTable> tables() {
return TABLES;
return catalog.objects();
}
public String defaultKey() {
return TABLES.getFirst().key();
return catalog.objects().getFirst().key();
}
public StructuredDataTable requireTable(String key) {
return TABLES.stream()
.filter(table -> table.key().equals(key))
.findFirst()
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
return catalog.require(key);
}
public StructuredDataPreview preview(String key) {
@@ -54,7 +43,7 @@ public class StructuredDataService {
AND table_name = ?
ORDER BY column_id
""",
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName());
(resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
if (columns.isEmpty()) {
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
}
@@ -63,24 +52,11 @@ public class StructuredDataService {
previewSql(table), ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
} catch (DataAccessException exception) {
throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.");
throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요.");
}
}
/**
* The table is selected from a closed application whitelist, so the query
* text remains fixed and no request value can become a SQL identifier.
*/
private String previewSql(StructuredDataTable table) {
return switch (table.key()) {
case "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?";
case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?";
case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?";
case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?";
case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?";
case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?";
case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?";
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
};
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
}
}

View File

@@ -27,7 +27,7 @@ public class DashboardController {
@GetMapping("/")
public String dashboard(Model model) {
// The Smilegate PoC home is an identity-administration landing page.
// The backoffice home is an identity-administration landing page.
// It intentionally does not query legacy CB_* VPD catalog objects.
model.addAttribute("users", userService.findAll());
model.addAttribute("groups", groupService.findAll());

View File

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

View File

@@ -114,6 +114,6 @@ public class SchemaMetadataController {
private String readMessage(Exception exception) {
return exception instanceof AppException
? exception.getMessage()
: "DB 메타데이터를 조회하지 못했습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.";
: "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요.";
}
}

View File

@@ -69,9 +69,25 @@ backoffice:
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
select-ai:
# Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
# not the ADMIN connection used by the backoffice control plane.
# Cloud AI profiles are schema-owned. This connection must use the profile owner's account,
# not the control-plane account used by the backoffice.
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
profile: ${BACKOFFICE_SELECT_AI_PROFILE:SGMP_POC_OCI_GPT54MINI}
profile: ${BACKOFFICE_SELECT_AI_PROFILE:}
catalog:
owner: ${BACKOFFICE_CATALOG_OWNER:}
objects: ${BACKOFFICE_CATALOG_OBJECTS:}
product:
name: ${BACKOFFICE_PRODUCT_NAME:Data & AI Backoffice}
title: ${BACKOFFICE_PRODUCT_TITLE:Data & AI Backoffice}
data-label: ${BACKOFFICE_PRODUCT_DATA_LABEL:업무 데이터}
mcp:
tool-name: ${BACKOFFICE_MCP_TOOL_NAME:oracle.select_ai.data_text2sql}
tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL}
tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.}
prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.}
masking:
policies: ${BACKOFFICE_MASKING_POLICIES:}
security-sql-scripts:
scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:}

View File

@@ -68,10 +68,9 @@
-->
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
WITH managed_policy AS (
SELECT 'CZN_COMN_USER_MST' AS object_name, 'SG_CZN_USER_REDACT' AS policy_name FROM dual
UNION ALL SELECT 'COMN_SALES_USER_MST', 'SG_SALES_USER_REDACT' FROM dual
UNION ALL SELECT 'COMN_SALES_TXN', 'SG_SALES_TXN_REDACT' FROM dual
UNION ALL SELECT 'COMN_REFUND_TXN', 'SG_REFUND_TXN_REDACT' FROM dual
<foreach collection="targets" item="target" separator=" UNION ALL ">
SELECT #{target.objectName} AS object_name, #{target.policyName} AS policy_name FROM dual
</foreach>
),
configured AS (
SELECT protected_object.object_name,
@@ -80,7 +79,7 @@
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_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'
GROUP BY protected_object.object_name
),
@@ -96,7 +95,7 @@
LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = policy.object_owner
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
),
missing_columns AS (
@@ -110,7 +109,7 @@
ON policy_column.object_owner = protected_object.owner
AND policy_column.object_name = protected_object.object_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 policy_column.column_name IS NULL
GROUP BY protected_object.object_name
@@ -120,14 +119,14 @@
COUNT(*) AS extra_column_count
FROM redaction_columns policy_column
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 (
SELECT 1
FROM sg_column_masking_rule link
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_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_column.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y'
@@ -138,15 +137,14 @@
SELECT policy.object_name,
COUNT(*) AS legacy_vpd_column_policy_count
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.policy_name IN (
'SG_CZN_USER_REDACT', 'SG_SALES_USER_REDACT',
'SG_SALES_TXN_REDACT', 'SG_REFUND_TXN_REDACT'
)
GROUP BY policy.object_name
)
SELECT 'SGMP_POC' AS owner,
SELECT #{owner} AS owner,
managed.object_name,
managed.policy_name,
database_policy.enable AS enabled,

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="backoffice-can-mutate" th:content="${canMutate}">
<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 &amp; AI Backoffice</title>
<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">
<!-- External UI helpers must not block initial HTML rendering. In
@@ -18,7 +18,7 @@
<body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container rw-nav-top">
<a class="navbar-brand" href="/">SMILEGATE DATA &amp; AI POC</a>
<a class="navbar-brand" href="/" th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data &amp; AI Backoffice</a>
<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="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>

View File

@@ -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>
<tbody>
<tr th:each="status : ${policyStatuses}">
<td><code th:text="${status.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
<td><code th:text="${status.policyName()}">SG_GAME_USER_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
<td th:text="${status.configuredColumnCount()}">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>
@@ -209,11 +209,11 @@
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
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>
</th:block>
</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>
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
</form>
@@ -253,7 +253,7 @@
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
<tbody>
<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.template().label()}">식별자 부분 마스킹</td>
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1>
<details class="explanation-details">
<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>
</section>
@@ -99,7 +99,7 @@
<td>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted">
HTTP <code>Authorization</code> → 활성 PoC 사용자 토큰 검증 · <code>prompt</code>게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · <code>prompt</code>업무 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
</small>
</td>
</tr>
@@ -116,9 +116,9 @@
<summary>tools/call parameter 예시 보기</summary>
<h2>tools/call Arguments</h2>
<pre class="code-block">{
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
}</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>
</section>
@@ -152,9 +152,9 @@
"id": 3,
"method": "tools/call",
"params": {
"name": "oracle.select_ai.smilegate_game_text2sql",
"name": "oracle.select_ai.data_text2sql",
"arguments": {
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
}
}
}</pre>

View File

@@ -159,8 +159,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
<tbody>
<tr th:each="status : ${maskingPolicyStatuses}">
<td><code th:text="${status.targetLabel()}">SGMP_POC.COMN_SALES_TXN</code></td>
<td><code th:text="${status.policyName()}">SG_SALES_TXN_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>

View File

@@ -29,7 +29,7 @@
<tbody>
<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><code th:text="${item.fileName()}">71_sg_identity_administration.sql</code></td>
<td><code th:text="${item.fileName()}">category/script.sql</code></td>
<td>
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
<div class="form-hint" th:text="${item.description()}">설명</div>
@@ -45,13 +45,13 @@
<section class="content-band" th:if="${selectedScript}">
<div class="section-heading">
<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>
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
</div>
<code th:text="${selectedScript.fileName()}">71_sg_identity_administration.sql</code>
<code th:text="${selectedScript.fileName()}">category/script.sql</code>
</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">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">

View File

@@ -8,19 +8,19 @@
<h1>정형 데이터 조회</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>스마일게이트 게임 데이터 테이블을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
<p>등록된 업무 데이터 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
</details>
</div>
<div class="alert alert-warning">
이 화면은 관리자용 게임 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
이 화면은 관리자용 업무 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
</div>
<section class="content-band">
<div class="section-heading">
<div>
<h2>조회할 게임 데이터 선택</h2>
<p class="section-subtitle"><code>SGMP_POC</code> 스키마에서 PoC 대상으로 등록한 게임 데이터 테이블만 표시합니다.</p>
<h2>조회할 업무 데이터 선택</h2>
<p class="section-subtitle"><code th:text="${catalogOwner}">OWNER</code> 스키마에서 등록한 <span th:text="${product.dataName()}">업무 데이터</span> 객체만 표시합니다.</p>
</div>
</div>
<div class="structured-table-grid">
@@ -29,7 +29,7 @@
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/structured-data(table=${entry.key()})}">
<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>
</a>
</div>
@@ -42,7 +42,7 @@
<div>
<h2 th:text="${preview.table().businessName()}">게임 사용자</h2>
<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>
</p>
</div>

View File

@@ -1,11 +1,11 @@
<!doctype html>
<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>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>Smilegate 액세스 토큰</h1>
<h1>액세스 토큰</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>Data &amp; AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>

View File

@@ -39,7 +39,7 @@
data-result=${columnRule.template().previewResult()},
data-aso-function=${columnRule.template().asoFunction()},
data-context=${'MR_' + columnRule.columnId()}"
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</option>
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
</select>
</label>
<div>
@@ -86,7 +86,7 @@
<tbody>
<tr th:each="userRule : ${userRules}">
<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><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>

View File

@@ -82,8 +82,8 @@
</thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
<td><code th:text="${policy.policyName()}">SG_CZN_USER_ROW_POLICY</code></td>
<td><code th:text="${policy.objectDisplayName()}">OWNER.OBJECT_NAME</code></td>
<td><code th:text="${policy.policyName()}">ROW_ACCESS_POLICY</code></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><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>

View File

@@ -23,8 +23,9 @@ class MaskingPolicySynchronizerTest {
}
@Test
void smilegateSalesTransactionPolicyIsManagedByBackoffice() {
assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN"))
.isEqualTo("SG_SALES_TXN_REDACT");
void redactionPolicyManagementIsNotBoundToAProductSpecificMapping() {
assertThat(MaskingPolicySynchronizer.class.getDeclaredFields())
.extracting(field -> field.getName())
.doesNotContain("MANAGED_POLICIES", "OWNER");
}
}

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -10,7 +11,7 @@ import org.junit.jupiter.api.Test;
class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService();
private final SelectAiService selectAiService = new CapturingSelectAiService();
private final McpSseService service = new McpSseService(
selectAiService,
objectMapper,
@@ -20,6 +21,12 @@ class McpSseServiceTest {
null,
null,
new BackofficeProperties.SelectAi("", "", "", "SGMP_POC_OCI_GPT54MINI")
),
new McpProperties(
"oracle.select_ai.test_data_text2sql",
"테스트 데이터 Text2SQL",
"테스트 데이터용 읽기 전용 SQL을 생성하고 실행합니다.",
"테스트 데이터의 조회 내용을 입력합니다."
)
);
@@ -30,7 +37,7 @@ class McpSseServiceTest {
var tools = response.path("result").path("tools");
assertThat(tools).hasSize(1);
var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql");
assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql");
assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI");
assertThat(selectAi.path("inputSchema").path("required"))
.extracting(node -> node.asText())
@@ -44,14 +51,13 @@ class McpSseServiceTest {
void callsVpdSelectAiThroughOrdsService() {
ObjectNode request = request(2, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
params.put("name", "oracle.select_ai.test_data_text2sql");
ObjectNode arguments = params.putObject("arguments");
arguments.put("prompt", "카제나 AU를 조회해 줘");
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingSmilegateSelectAiService agentService =
(CapturingSmilegateSelectAiService) selectAiService;
CapturingSelectAiService agentService = (CapturingSelectAiService) selectAiService;
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
assertThat(response.path("error").isMissingNode()).isTrue();
@@ -69,7 +75,7 @@ class McpSseServiceTest {
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
ObjectNode request = request(3, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
params.put("name", "oracle.select_ai.test_data_text2sql");
params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘");
ObjectNode response = service.handle("default", request, "");
@@ -89,12 +95,12 @@ class McpSseServiceTest {
return request;
}
private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService {
private static final class CapturingSelectAiService extends SelectAiService {
private String bearerToken;
private String prompt;
private CapturingSmilegateSelectAiService() {
private CapturingSelectAiService() {
super(null, null, null, new ObjectMapper());
}

View File

@@ -29,10 +29,12 @@ class SchemaMetadataServiceTest {
void setUp() {
mapper = mock(SchemaMetadataMapper.class);
StructuredDataService structuredDataService = mock(StructuredDataService.class);
DataCatalog catalog = mock(DataCatalog.class);
when(catalog.owner()).thenReturn("SGMP_POC");
when(structuredDataService.requireTable("game-users")).thenReturn(GAME_USERS);
when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS));
when(structuredDataService.defaultKey()).thenReturn("game-users");
service = new SchemaMetadataService(mapper, structuredDataService);
service = new SchemaMetadataService(mapper, structuredDataService, catalog);
}
@Test

View File

@@ -3,22 +3,31 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class SecuritySqlScriptServiceTest {
private final SecuritySqlScriptService service = new SecuritySqlScriptService();
private final SecuritySqlScriptService service = new SecuritySqlScriptService(
new SecuritySqlScriptProperties("""
[{"scriptId":"tool-users","category":"사용자","fileName":"legacy/smilegate/70_tool_user.sql",
"title":"도구 사용자","description":"도구 사용자 초기 데이터"},
{"scriptId":"identity-administration","category":"권한","fileName":"legacy/smilegate/71_identity_administration.sql",
"title":"권한 관리","description":"권한 메타데이터"}]
"""),
new ObjectMapper()
);
@Test
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
assertThat(service.list())
.extracting(item -> item.fileName())
.containsExactly(
"70_sg_tool_user.sql",
"71_sg_identity_administration.sql"
"legacy/smilegate/70_tool_user.sql", "legacy/smilegate/71_identity_administration.sql"
);
assertThat(service.find("smilegate-identity-administration").source())
assertThat(service.find("identity-administration").source())
.contains("create table sg_app_user")
.contains("DATA_AI_TF");
}

View File

@@ -6,29 +6,30 @@ import static org.mockito.Mockito.mock;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doThrow;
class StructuredDataServiceTest {
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class));
private final DataCatalog catalog = mock(DataCatalog.class);
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class), catalog);
@Test
void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
when(catalog.objects()).thenReturn(List.of(
new com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래")));
assertThat(service.tables())
.extracting(table -> table.tableName())
.containsExactly(
"CZN_COMN_USER_MST",
"CZN_COMN_CHARACTER_MST",
"COMN_SALES_TXN",
"COMN_REFUND_TXN",
"COMN_SALES_PRODUCT_DISP_BAS",
"COMN_GAME_SERVER_BAS",
"COMN_GAME_ALIAS_BAS");
.containsExactly("COMN_SALES_TXN");
}
@Test
void rejectsAnyTableOutsideTheServerSideAllowlist() {
doThrow(new AppException("선택할 수 없는 카탈로그 객체입니다."))
.when(catalog).require("security-audit-log");
assertThatThrownBy(() -> service.requireTable("security-audit-log"))
.isInstanceOf(AppException.class)
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
}
}