refs #722: externalize backoffice customer configuration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Deployment-provided allow-list for the structured-data and metadata screens. */
|
||||
@ConfigurationProperties(prefix = "backoffice.catalog")
|
||||
public record CatalogProperties(String owner, String objects) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** JSON configuration of database redaction policies this backoffice may manage. */
|
||||
/** JSON configuration of database redaction policies this backoffice is allowed to manage. */
|
||||
@ConfigurationProperties(prefix = "backoffice.masking")
|
||||
public record MaskingProperties(String policies) {
|
||||
}
|
||||
|
||||
@@ -2,36 +2,23 @@ package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Product-neutral MCP endpoint labels and tool catalogue configuration. */
|
||||
/** Product-neutral labels and endpoint details for the MCP Select AI tool. */
|
||||
@ConfigurationProperties(prefix = "backoffice.mcp")
|
||||
public record McpProperties(
|
||||
String publicUrl,
|
||||
String serverName,
|
||||
String toolName,
|
||||
String toolLabel,
|
||||
String toolDescription,
|
||||
String promptDescription,
|
||||
String tools
|
||||
String promptDescription
|
||||
) {
|
||||
|
||||
private static final String DEFAULT_PUBLIC_URL = "/mcp";
|
||||
private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice";
|
||||
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
|
||||
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
|
||||
private static final String DEFAULT_TOOL_DESCRIPTION =
|
||||
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 "
|
||||
+ "트랜잭션에서 실행합니다.";
|
||||
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. "
|
||||
+ "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.";
|
||||
private static final String DEFAULT_PROMPT_DESCRIPTION =
|
||||
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
|
||||
|
||||
public String resolvedPublicUrl() {
|
||||
return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL);
|
||||
}
|
||||
|
||||
public String resolvedServerName() {
|
||||
return requiredOrDefault(serverName, DEFAULT_SERVER_NAME);
|
||||
}
|
||||
|
||||
public String resolvedToolName() {
|
||||
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,9 @@ package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Customer-facing labels that do not affect authorization or database identity. */
|
||||
@ConfigurationProperties(prefix = "backoffice.product")
|
||||
public record ProductProperties(String name, String title, String dataLabel) {
|
||||
|
||||
public String displayName() {
|
||||
return name == null || name.isBlank() ? "Data & AI Backoffice" : name.trim();
|
||||
}
|
||||
|
||||
public String pageTitle() {
|
||||
return title == null || title.isBlank() ? displayName() : title.trim();
|
||||
}
|
||||
|
||||
public String dataName() {
|
||||
return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel.trim();
|
||||
}
|
||||
public String displayName() { return name == null || name.isBlank() ? "Data & AI Backoffice" : name; }
|
||||
public String pageTitle() { return title == null || title.isBlank() ? displayName() : title; }
|
||||
public String dataName() { return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel; }
|
||||
}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.structured;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record StructuredDataTable(
|
||||
String key,
|
||||
String tableName,
|
||||
String objectType,
|
||||
String businessName,
|
||||
String description,
|
||||
List<String> previewColumns
|
||||
String description
|
||||
) {
|
||||
|
||||
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
||||
this(key, tableName, "TABLE", businessName, description, List.of());
|
||||
}
|
||||
|
||||
public boolean isTable() {
|
||||
return "TABLE".equals(objectType);
|
||||
this(key, tableName, "TABLE", businessName, description);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public interface MaskingRuleMapper {
|
||||
|
||||
List<MaskingPolicyStatus> findPolicyStatuses(
|
||||
@Param("owner") String owner,
|
||||
@Param("policies") List<MaskingPolicyTarget> policies
|
||||
@Param("targets") List<MaskingPolicyTarget> targets
|
||||
);
|
||||
|
||||
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
||||
|
||||
@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final BackofficeProperties properties;
|
||||
private final DataCatalog dataCatalog;
|
||||
|
||||
public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) {
|
||||
public BackofficeSchemaService(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
BackofficeProperties properties,
|
||||
DataCatalog dataCatalog
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.properties = properties;
|
||||
this.dataCatalog = dataCatalog;
|
||||
}
|
||||
|
||||
public SchemaPreflightView preflight() {
|
||||
@@ -712,7 +718,7 @@ public class BackofficeSchemaService {
|
||||
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
||||
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
||||
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||
@sql/adb/71_sg_identity_administration.sql
|
||||
-- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행
|
||||
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||
|
||||
-- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한
|
||||
@@ -720,9 +726,9 @@ public class BackofficeSchemaService {
|
||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||
GRANT SELECT ON <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) {
|
||||
|
||||
@@ -4,10 +4,7 @@ import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import java.util.List;
|
||||
|
||||
public interface DataCatalog {
|
||||
|
||||
String owner();
|
||||
|
||||
List<StructuredDataTable> objects();
|
||||
|
||||
StructuredDataTable require(String key);
|
||||
}
|
||||
|
||||
@@ -4,119 +4,51 @@ import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Validated deployment allow-list for data preview and metadata operations. */
|
||||
@Service
|
||||
public class EnvironmentDataCatalog implements DataCatalog {
|
||||
|
||||
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
private static final Pattern KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||
|
||||
private final String owner;
|
||||
private final List<StructuredDataTable> objects;
|
||||
|
||||
public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper objectMapper) {
|
||||
try {
|
||||
owner = requireName(properties.owner(), "BACKOFFICE_CATALOG_OWNER");
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.", exception);
|
||||
}
|
||||
objects = parse(properties.objects(), objectMapper);
|
||||
public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper mapper) {
|
||||
owner = requireName(properties.owner());
|
||||
objects = parse(properties.objects(), mapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String owner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StructuredDataTable> objects() {
|
||||
return objects;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StructuredDataTable require(String key) {
|
||||
return objects.stream()
|
||||
.filter(item -> item.key().equals(key))
|
||||
.findFirst()
|
||||
@Override public String owner() { return owner; }
|
||||
@Override public List<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 objectMapper) {
|
||||
private List<StructuredDataTable> parse(String raw, ObjectMapper mapper) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
|
||||
}
|
||||
try {
|
||||
List<StructuredDataTable> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
if (parsed.isEmpty()) {
|
||||
throw new IllegalArgumentException("카탈로그 객체가 비어 있습니다.");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
Set<String> objectNames = new HashSet<>();
|
||||
List<StructuredDataTable> normalized = parsed.stream()
|
||||
.map(this::normalize)
|
||||
.peek(item -> {
|
||||
if (!keys.add(item.key())) {
|
||||
throw new IllegalArgumentException("중복 key: " + item.key());
|
||||
}
|
||||
if (!objectNames.add(item.tableName())) {
|
||||
throw new IllegalArgumentException("중복 tableName: " + item.tableName());
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
return List.copyOf(normalized);
|
||||
List<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 StructuredDataTable normalize(StructuredDataTable value) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("null 카탈로그 객체");
|
||||
}
|
||||
String key = requiredText(value.key(), "key").toLowerCase(Locale.ROOT);
|
||||
if (!KEY.matcher(key).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 key: " + value.key());
|
||||
}
|
||||
String objectName = requireName(value.tableName(), "tableName");
|
||||
String objectType = requiredText(value.objectType(), "objectType").toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("TABLE", "VIEW").contains(objectType)) {
|
||||
throw new IllegalArgumentException("잘못된 objectType: " + value.objectType());
|
||||
}
|
||||
List<String> previewColumns = value.previewColumns() == null
|
||||
? List.of()
|
||||
: value.previewColumns().stream()
|
||||
.map(column -> requireName(column, "previewColumns"))
|
||||
.distinct()
|
||||
.toList();
|
||||
return new StructuredDataTable(
|
||||
key,
|
||||
objectName,
|
||||
objectType,
|
||||
requiredText(value.businessName(), "businessName"),
|
||||
requiredText(value.description(), "description"),
|
||||
List.copyOf(previewColumns)
|
||||
);
|
||||
private void validate(StructuredDataTable value) {
|
||||
if (value == null || value.key() == null || !KEY.matcher(value.key()).matches()
|
||||
|| !NAME.matcher(value.tableName().toUpperCase(Locale.ROOT)).matches()
|
||||
|| !("TABLE".equalsIgnoreCase(value.objectType()) || "VIEW".equalsIgnoreCase(value.objectType()))) throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
private String requireName(String value, String field) {
|
||||
String normalized = requiredText(value, field).toUpperCase(Locale.ROOT);
|
||||
if (!NAME.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다.");
|
||||
}
|
||||
private String requireName(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!NAME.matcher(normalized).matches()) throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requiredText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " 값은 필수입니다.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,18 @@ package com.cloudhandson.vpdbackoffice.service;
|
||||
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Loads the managed Data Redaction allow-list from deployment configuration. */
|
||||
/** Loads the managed redaction policy allow-list from BACKOFFICE_MASKING_POLICIES. */
|
||||
@Service
|
||||
public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog {
|
||||
|
||||
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
|
||||
private final List<MaskingPolicyTarget> targets;
|
||||
|
||||
public EnvironmentMaskingPolicyCatalog(
|
||||
MaskingProperties properties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
public EnvironmentMaskingPolicyCatalog(MaskingProperties properties, ObjectMapper objectMapper) {
|
||||
targets = parse(properties.policies(), objectMapper);
|
||||
}
|
||||
|
||||
@@ -36,28 +29,12 @@ public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog {
|
||||
}
|
||||
try {
|
||||
List<MaskingPolicyTarget> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
if (parsed.isEmpty()) {
|
||||
throw new IllegalArgumentException("마스킹 정책 목록이 비어 있습니다.");
|
||||
if (parsed.isEmpty()
|
||||
|| parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
Set<String> objectNames = new HashSet<>();
|
||||
Set<String> policyNames = new HashSet<>();
|
||||
List<MaskingPolicyTarget> normalized = parsed.stream()
|
||||
.map(item -> {
|
||||
if (item == null) {
|
||||
throw new IllegalArgumentException("null 마스킹 정책");
|
||||
}
|
||||
return new MaskingPolicyTarget(
|
||||
normalize(item.objectName()),
|
||||
normalize(item.policyName()));
|
||||
})
|
||||
.peek(item -> {
|
||||
if (!objectNames.add(item.objectName())) {
|
||||
throw new IllegalArgumentException("중복 objectName: " + item.objectName());
|
||||
}
|
||||
if (!policyNames.add(item.policyName())) {
|
||||
throw new IllegalArgumentException("중복 policyName: " + item.policyName());
|
||||
}
|
||||
})
|
||||
.map(item -> new MaskingPolicyTarget(normalize(item.objectName()), normalize(item.policyName())))
|
||||
.toList();
|
||||
return List.copyOf(normalized);
|
||||
} catch (Exception exception) {
|
||||
@@ -68,7 +45,7 @@ public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog {
|
||||
private String normalize(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!NAME.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException("Oracle 식별자 형식이 올바르지 않습니다.");
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface MaskingPolicyCatalog {
|
||||
|
||||
List<MaskingPolicyTarget> targets();
|
||||
|
||||
default Set<String> objectNames() {
|
||||
return targets().stream()
|
||||
.map(MaskingPolicyTarget::objectName)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
return targets().stream().map(MaskingPolicyTarget::objectName).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
default boolean containsObject(String objectName) {
|
||||
return objectName != null
|
||||
&& objectNames().contains(objectName.trim().toUpperCase(Locale.ROOT));
|
||||
return objectName != null && objectNames().contains(objectName.trim().toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class MaskingPolicySynchronizer {
|
||||
|
||||
private static final String OWNER = "SGMP_POC";
|
||||
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
@@ -39,41 +38,18 @@ public class MaskingPolicySynchronizer {
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
private static Map<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 policyCatalog.objectNames();
|
||||
}
|
||||
|
||||
public String owner() {
|
||||
return dataCatalog.owner();
|
||||
}
|
||||
|
||||
public boolean isManagedObject(String objectName) {
|
||||
return policyCatalog.containsObject(objectName);
|
||||
}
|
||||
|
||||
String managedPolicyName(String objectName) {
|
||||
return policyCatalog.targets().stream()
|
||||
.filter(target -> target.objectName().equals(objectName))
|
||||
.map(MaskingPolicyTarget::policyName)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
List<MaskingPolicyTarget> managedPolicies() {
|
||||
return policyCatalog.targets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the current active column-rule metadata to managed DBMS_REDACT policies.
|
||||
*
|
||||
@@ -331,7 +307,7 @@ public class MaskingPolicySynchronizer {
|
||||
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
||||
);
|
||||
END;
|
||||
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** A validated database object-to-redaction-policy mapping. */
|
||||
/** A validated object-to-redaction-policy mapping supplied by deployment configuration. */
|
||||
public record MaskingPolicyTarget(String objectName, String policyName) {
|
||||
}
|
||||
|
||||
@@ -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,13 +61,12 @@ public class MaskingRuleService {
|
||||
return mapper.findColumnRules();
|
||||
}
|
||||
|
||||
/** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
|
||||
/** Reads the actual Oracle Data Redaction state for configured managed objects. */
|
||||
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||
List<MaskingPolicyTarget> policies = maskingPolicySynchronizer.managedPolicies();
|
||||
if (policies.isEmpty()) {
|
||||
if (maskingPolicyCatalog.targets().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies);
|
||||
return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets());
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,13 +29,15 @@ public class SchemaMetadataService {
|
||||
|
||||
private final SchemaMetadataMapper mapper;
|
||||
private final StructuredDataService structuredDataService;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public SchemaMetadataService(
|
||||
SchemaMetadataMapper mapper,
|
||||
StructuredDataService structuredDataService
|
||||
StructuredDataService structuredDataService, DataCatalog catalog
|
||||
) {
|
||||
this.mapper = mapper;
|
||||
this.structuredDataService = structuredDataService;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
@@ -57,7 +59,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
|
||||
);
|
||||
@@ -68,7 +70,7 @@ public class SchemaMetadataService {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
|
||||
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -77,7 +79,7 @@ public class SchemaMetadataService {
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String column = requireColumn(tableName, columnName);
|
||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
|
||||
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -109,16 +111,16 @@ public class SchemaMetadataService {
|
||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||
if (annotationExists(tableName, columnName, key)) {
|
||||
if (columnName == null) {
|
||||
mapper.dropTableAnnotation(OWNER, tableName, key);
|
||||
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
|
||||
} else {
|
||||
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
|
||||
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
|
||||
}
|
||||
}
|
||||
if (!value.isBlank()) {
|
||||
if (columnName == null) {
|
||||
mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
|
||||
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
|
||||
} else {
|
||||
mapper.addColumnAnnotation(OWNER, tableName, columnName, key, quoteLiteral(value));
|
||||
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +129,7 @@ public class SchemaMetadataService {
|
||||
String tableName,
|
||||
Map<String, List<SchemaAnnotation>> annotations
|
||||
) {
|
||||
return mapper.findColumns(OWNER, tableName).stream()
|
||||
return mapper.findColumns(catalog.owner(), tableName).stream()
|
||||
.map(row -> toColumn(row, annotations))
|
||||
.toList();
|
||||
}
|
||||
@@ -178,7 +180,7 @@ public class SchemaMetadataService {
|
||||
|
||||
private String requireColumn(String tableName, String columnName) {
|
||||
String column = requireSimpleName(columnName, "column name");
|
||||
if (mapper.countColumn(OWNER, tableName, column) == 0) {
|
||||
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
|
||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||
}
|
||||
return column;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.util.Locale;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Generates and executes bounded read-only SQL through a configured Select AI profile. */
|
||||
/**
|
||||
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
|
||||
*/
|
||||
@Service
|
||||
public class SelectAiService {
|
||||
|
||||
@@ -35,47 +36,35 @@ public class SelectAiService {
|
||||
);
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final Clock clock;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SelectAiService(
|
||||
BackofficeProperties properties,
|
||||
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||
BearerTokenService bearerTokenService,
|
||||
Clock clock,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.bearerAuthenticator = bearerAuthenticator;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.clock = clock;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||
HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken);
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi =
|
||||
properties == null ? null : properties.selectAi();
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요.");
|
||||
}
|
||||
if (!selectAi.runtimeConfigured()) {
|
||||
throw new AppException("VPD 런타임 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_URL, "
|
||||
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD를 확인하세요.");
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||
}
|
||||
|
||||
String generatedSql = generate(
|
||||
selectAi,
|
||||
vpdAwarePrompt(
|
||||
normalizedPrompt,
|
||||
principal,
|
||||
loadQueryContract(selectAi.queryContractFile())
|
||||
)
|
||||
);
|
||||
String generatedSql = generate(selectAi, normalizedPrompt);
|
||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||
QueryExecution execution = executeReadOnly(
|
||||
selectAi, normalizedSql, bearerToken.trim(), principal);
|
||||
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||
response.put("profile", selectAi.profile());
|
||||
@@ -83,15 +72,24 @@ public class SelectAiService {
|
||||
response.put("execution", "READ_ONLY_EXECUTED");
|
||||
response.put("rowCount", execution.items().size());
|
||||
response.put("truncated", execution.truncated());
|
||||
response.put("vpdEnforced", true);
|
||||
response.put("scopeEmployeeCode", principal.employeeCode());
|
||||
response.set("items", execution.items());
|
||||
response.put("nextStep", execution.truncated()
|
||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다."
|
||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
||||
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
||||
return response;
|
||||
}
|
||||
|
||||
private void requireActiveToken(String bearerToken) {
|
||||
if (bearerToken == null || bearerToken.isBlank()) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
if (token == null || !token.active(now)) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredPrompt(String prompt) {
|
||||
String normalized = prompt == null ? "" : prompt.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
@@ -103,57 +101,7 @@ public class SelectAiService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String vpdAwarePrompt(
|
||||
String prompt,
|
||||
HmmMcpPrincipal principal,
|
||||
String queryContract
|
||||
) {
|
||||
String basePrompt = """
|
||||
Oracle SQL 생성 규칙:
|
||||
- 프로필에 승인된 HMM HR 객체만 사용하세요.
|
||||
- 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요.
|
||||
- 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요.
|
||||
- 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요.
|
||||
|
||||
사용자 질문: %s
|
||||
""".formatted(principal.employeeCode(), prompt);
|
||||
if (queryContract == null || queryContract.isBlank()) {
|
||||
return basePrompt;
|
||||
}
|
||||
return basePrompt + """
|
||||
|
||||
배포별 질의 계약(JSON):
|
||||
아래 계약 중 사용자 질문에 해당하는 항목만 적용하세요. 필수 필드, 계산,
|
||||
시간 기준, 누락 레코드 의미와 금지 fallback을 그대로 지키세요.
|
||||
""" + queryContract;
|
||||
}
|
||||
|
||||
private String loadQueryContract(String configuredPath) {
|
||||
if (configuredPath == null || configuredPath.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Path path = Path.of(configuredPath.trim()).toAbsolutePath().normalize();
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw new AppException("Select AI 질의 계약 파일을 찾을 수 없습니다: " + path);
|
||||
}
|
||||
long size = Files.size(path);
|
||||
if (size < 2 || size > 128_000) {
|
||||
throw new AppException("Select AI 질의 계약 파일 크기가 허용 범위를 벗어났습니다.");
|
||||
}
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Select AI 질의 계약 파일을 읽지 못했습니다: "
|
||||
+ safeMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private String generate(
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String prompt
|
||||
) {
|
||||
private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
|
||||
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
@@ -169,126 +117,50 @@ public class SelectAiService {
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Select AI SHOWSQL 생성 실패: " + safeMessage(exception));
|
||||
throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private QueryExecution executeReadOnly(
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String generatedSql,
|
||||
String bearerToken,
|
||||
HmmMcpPrincipal principal
|
||||
) {
|
||||
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
||||
ArrayNode items = objectMapper.createArrayNode();
|
||||
boolean truncated = false;
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.runtimeDbUrl(),
|
||||
selectAi.runtimeDbUsername(),
|
||||
selectAi.runtimeDbPassword())) {
|
||||
verifyNonExemptRuntime(connection);
|
||||
boolean contextSet = false;
|
||||
try {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) {
|
||||
statement.setString(1, "Bearer " + bearerToken);
|
||||
statement.execute();
|
||||
contextSet = true;
|
||||
}
|
||||
verifyVpdContext(connection, principal);
|
||||
connection.setAutoCommit(false);
|
||||
connection.setReadOnly(true);
|
||||
try (Statement transaction = connection.createStatement()) {
|
||||
transaction.execute("SET TRANSACTION READ ONLY");
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||
while (resultSet.next()) {
|
||||
if (items.size() >= MAX_RESULT_ROWS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
ObjectNode row = items.addObject();
|
||||
for (int columnIndex = 1;
|
||||
columnIndex <= metadata.getColumnCount();
|
||||
columnIndex++) {
|
||||
String column = metadata.getColumnLabel(columnIndex);
|
||||
if (column == null || column.isBlank()) {
|
||||
column = metadata.getColumnName(columnIndex);
|
||||
}
|
||||
putResultValue(row, column, resultSet, columnIndex);
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
Statement transaction = connection.createStatement()) {
|
||||
connection.setAutoCommit(false);
|
||||
connection.setReadOnly(true);
|
||||
transaction.execute("SET TRANSACTION READ ONLY");
|
||||
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||
while (resultSet.next()) {
|
||||
if (items.size() >= MAX_RESULT_ROWS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
ObjectNode row = items.addObject();
|
||||
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
||||
String column = metadata.getColumnLabel(columnIndex);
|
||||
if (column == null || column.isBlank()) {
|
||||
column = metadata.getColumnName(columnIndex);
|
||||
}
|
||||
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
connection.rollback();
|
||||
} finally {
|
||||
if (contextSet) {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) {
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.rollback();
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception));
|
||||
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||
}
|
||||
return new QueryExecution(items, truncated);
|
||||
}
|
||||
|
||||
private void verifyNonExemptRuntime(Connection connection) throws Exception {
|
||||
String runtimeUser;
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery("SELECT USER FROM dual")) {
|
||||
if (!resultSet.next()) {
|
||||
throw new AppException("VPD 런타임 DB 사용자를 확인할 수 없습니다.");
|
||||
}
|
||||
runtimeUser = resultSet.getString(1);
|
||||
}
|
||||
if (runtimeUser == null || "ADMIN".equals(runtimeUser.toUpperCase(Locale.ROOT))) {
|
||||
throw new AppException("VPD 런타임 연결은 ADMIN을 사용할 수 없습니다.");
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT COUNT(*) FROM SESSION_PRIVS WHERE PRIVILEGE = ?")) {
|
||||
statement.setString(1, "EXEMPT ACCESS POLICY");
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next() || resultSet.getInt(1) != 0) {
|
||||
throw new AppException(
|
||||
"VPD 런타임 계정에 EXEMPT ACCESS POLICY가 있어 실행을 차단했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyVpdContext(
|
||||
Connection connection,
|
||||
HmmMcpPrincipal principal
|
||||
) throws Exception {
|
||||
String sql = "SELECT SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE') FROM dual";
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery(sql)) {
|
||||
String employeeCode =
|
||||
resultSet.next() ? resultSet.getString(1) : null;
|
||||
if (employeeCode == null
|
||||
|| !employeeCode.equalsIgnoreCase(principal.employeeCode())) {
|
||||
throw new AppException("Bearer Token 사용자와 VPD 세션 컨텍스트가 일치하지 않습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void putResultValue(
|
||||
ObjectNode row,
|
||||
String column,
|
||||
ResultSet resultSet,
|
||||
int columnIndex
|
||||
) throws Exception {
|
||||
Object value = resultSet.getObject(columnIndex);
|
||||
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||
if (value == null) {
|
||||
row.putNull(column);
|
||||
} else if (value instanceof BigDecimal number) {
|
||||
@@ -308,13 +180,11 @@ public class SelectAiService {
|
||||
} else if (value instanceof Boolean bool) {
|
||||
row.put(column, bool);
|
||||
} else {
|
||||
// Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as
|
||||
// oracle.sql.TIMESTAMPTZ@<identity>, which is not usable MCP evidence.
|
||||
row.put(column, resultSet.getString(columnIndex));
|
||||
row.put(column, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
String validateReadOnlySql(String generatedSql) {
|
||||
private String validateReadOnlySql(String generatedSql) {
|
||||
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
||||
if (normalized.startsWith("```")) {
|
||||
int firstLineEnd = normalized.indexOf('\n');
|
||||
@@ -328,24 +198,14 @@ public class SelectAiService {
|
||||
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
|
||||
}
|
||||
if (normalized.contains(";")) {
|
||||
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 실행하지 않습니다.");
|
||||
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
||||
}
|
||||
if (normalized.contains("--")
|
||||
|| normalized.contains("/*")
|
||||
|| normalized.contains("*/")
|
||||
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank()
|
||||
? exception.getClass().getSimpleName()
|
||||
: message;
|
||||
}
|
||||
|
||||
private record QueryExecution(ArrayNode items, boolean truncated) {
|
||||
}
|
||||
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -12,26 +12,14 @@ import org.springframework.stereotype.Service;
|
||||
public class StructuredDataService {
|
||||
|
||||
private static final int ROW_LIMIT = 50;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public StructuredDataService(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
DataCatalog catalog
|
||||
) {
|
||||
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public DataCatalog catalog() {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
public String owner() {
|
||||
return catalog.owner();
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
return catalog.objects();
|
||||
}
|
||||
@@ -54,48 +42,21 @@ public class StructuredDataService {
|
||||
WHERE owner = ?
|
||||
AND table_name = ?
|
||||
ORDER BY column_id
|
||||
""",
|
||||
""",
|
||||
(resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
|
||||
if (columns.isEmpty()) {
|
||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
List<String> previewColumns =
|
||||
table.previewColumns().isEmpty() ? columns : table.previewColumns();
|
||||
if (!columns.containsAll(previewColumns)) {
|
||||
throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
previewSql(table, previewColumns), ROW_LIMIT);
|
||||
return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT);
|
||||
previewSql(table), ROW_LIMIT);
|
||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
||||
} catch (DataAccessException exception) {
|
||||
throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner()
|
||||
+ " 조회 권한과 대상 객체 상태를 확인하세요.");
|
||||
throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The table is selected from a closed application whitelist, so the query
|
||||
* text remains fixed and no request value can become a SQL identifier.
|
||||
*/
|
||||
String previewSql(StructuredDataTable table) {
|
||||
return previewSql(table, table.previewColumns());
|
||||
}
|
||||
|
||||
private String previewSql(
|
||||
StructuredDataTable table,
|
||||
List<String> previewColumns
|
||||
) {
|
||||
StructuredDataTable approved = requireTable(table.key());
|
||||
if (!approved.tableName().equals(table.tableName())) {
|
||||
throw new AppException("선택할 수 없는 카탈로그 객체입니다.");
|
||||
}
|
||||
String projection = previewColumns == null || previewColumns.isEmpty()
|
||||
? "*"
|
||||
: previewColumns.stream()
|
||||
.map(column -> "\"" + column + "\"")
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElseThrow();
|
||||
return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName()
|
||||
+ "\" WHERE ROWNUM <= ?";
|
||||
private String previewSql(StructuredDataTable table) {
|
||||
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.ProductProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.cloudhandson.vpdbackoffice.service.DataCatalog;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
/** Supplies deployment labels to every server-rendered page. */
|
||||
@ControllerAdvice
|
||||
public class ProductModelAdvice {
|
||||
|
||||
private final ProductProperties product;
|
||||
private final DataCatalog catalog;
|
||||
private final McpProperties mcp;
|
||||
|
||||
public ProductModelAdvice(
|
||||
ProductProperties product,
|
||||
DataCatalog catalog,
|
||||
McpProperties mcp
|
||||
) {
|
||||
public ProductModelAdvice(ProductProperties product, DataCatalog catalog, McpProperties mcp) {
|
||||
this.product = product;
|
||||
this.catalog = catalog;
|
||||
this.mcp = mcp;
|
||||
}
|
||||
|
||||
@ModelAttribute("product")
|
||||
ProductProperties product() {
|
||||
return product;
|
||||
}
|
||||
|
||||
ProductProperties product() { return product; }
|
||||
@ModelAttribute("catalogOwner")
|
||||
String catalogOwner() {
|
||||
return catalog.owner();
|
||||
}
|
||||
|
||||
String catalogOwner() { return catalog.owner(); }
|
||||
@ModelAttribute("mcp")
|
||||
McpProperties mcp() {
|
||||
return mcp;
|
||||
}
|
||||
McpProperties mcp() { return mcp; }
|
||||
}
|
||||
|
||||
@@ -115,6 +115,6 @@ public class SchemaMetadataController {
|
||||
private String readMessage(Exception exception) {
|
||||
return exception instanceof AppException
|
||||
? exception.getMessage()
|
||||
: "DB 메타데이터를 조회하지 못했습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.";
|
||||
: "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,7 +91,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>
|
||||
@@ -108,9 +108,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>
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "oracle.select_ai.smilegate_game_text2sql",
|
||||
"name": "oracle.select_ai.data_text2sql",
|
||||
"arguments": {
|
||||
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
|
||||
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}">
|
||||
|
||||
@@ -8,20 +8,19 @@
|
||||
<h1>정형 데이터 조회</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>환경 설정에 등록된 <span th:text="${product.dataName()}">업무 데이터</span> 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.</p>
|
||||
<p class="mb-0">한 번에 최대 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 th:text="${catalogOwner}">OWNER</code> 스키마에서 환경 설정으로 승인한 TABLE/VIEW만 표시합니다.</p>
|
||||
<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,9 +28,9 @@
|
||||
class="structured-table-card"
|
||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||
th:href="@{/structured-data(table=${entry.key()})}">
|
||||
<strong th:text="${entry.businessName()}">직원 원장</strong>
|
||||
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
||||
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
|
||||
<small><span th:text="${entry.objectType()}">TABLE</span> · <span th:text="${entry.description()}">업무 데이터</span></small>
|
||||
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -43,7 +42,7 @@
|
||||
<div>
|
||||
<h2 th:text="${preview.table().businessName()}">직원 원장</h2>
|
||||
<p class="section-subtitle">
|
||||
<code th:text="${catalogOwner + '.' + preview.table().tableName()}">OWNER.OBJECT_NAME</code>
|
||||
<code th:text="${catalogOwner + '.' + preview.table().tableName()}">OWNER.TABLE_NAME</code>
|
||||
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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 & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user