@@ -74,16 +74,12 @@ public record BackofficeProperties(
|
||||
}
|
||||
}
|
||||
|
||||
/** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
|
||||
/** Separate ADB connection because Select AI profiles are owned by SGMP_POC. */
|
||||
public record SelectAi(
|
||||
String dbUrl,
|
||||
String dbUsername,
|
||||
String dbPassword,
|
||||
String profile,
|
||||
String runtimeDbUrl,
|
||||
String runtimeDbUsername,
|
||||
String runtimeDbPassword,
|
||||
String queryContractFile
|
||||
String profile
|
||||
) {
|
||||
|
||||
public boolean configured() {
|
||||
@@ -92,12 +88,5 @@ public record BackofficeProperties(
|
||||
&& dbPassword != null && !dbPassword.isBlank()
|
||||
&& profile != null && !profile.isBlank();
|
||||
}
|
||||
|
||||
/** The generated SQL must never fall back to the privileged profile-owner connection. */
|
||||
public boolean runtimeConfigured() {
|
||||
return runtimeDbUrl != null && !runtimeDbUrl.isBlank()
|
||||
&& runtimeDbUsername != null && !runtimeDbUsername.isBlank()
|
||||
&& runtimeDbPassword != null && !runtimeDbPassword.isBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,6 @@ public class DbPoolWarmup {
|
||||
groupService.findGroupRoles();
|
||||
permissionService.findRoles();
|
||||
permissionService.findPermissionViews();
|
||||
log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
|
||||
log.info("Smilegate identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ public enum MaskingTemplate {
|
||||
"A******** (예시)"),
|
||||
RRN_PARTIAL(
|
||||
"RRN_PARTIAL",
|
||||
"주민등록번호 부분 마스킹",
|
||||
"앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
|
||||
"식별번호 부분 마스킹",
|
||||
"앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.",
|
||||
"DBMS_REDACT.REGEXP",
|
||||
"900101-******* (예시)");
|
||||
"123456-******* (예시)");
|
||||
|
||||
private final String code;
|
||||
private final String label;
|
||||
|
||||
@@ -50,16 +50,16 @@ public record PermissionView(
|
||||
return "토큰으로 식별된 이해관계자 본인 행";
|
||||
}
|
||||
if (upper.contains(" OWN_CONTRACT")) {
|
||||
return "담당 설계사 본인 계약";
|
||||
return "담당 게임 서비스 범위";
|
||||
}
|
||||
if (upper.contains(" CHANNEL_CONTRACT")) {
|
||||
return "토큰 사용자의 채널 계약";
|
||||
return "토큰 채널 게임 서비스 범위";
|
||||
}
|
||||
if (upper.contains(" OWN_CUSTOMER")) {
|
||||
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
|
||||
return "담당 게임 사용자·거래 데이터 범위";
|
||||
}
|
||||
if (upper.contains(" CHANNEL_CUSTOMER")) {
|
||||
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
|
||||
return "토큰 채널 게임 사용자·거래 데이터 범위";
|
||||
}
|
||||
if (upper.contains(" STATIC_SQL ")) {
|
||||
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
|
||||
|
||||
@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
|
||||
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
|
||||
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
|
||||
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
|
||||
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
|
||||
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
|
||||
new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
|
||||
"앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
|
||||
);
|
||||
|
||||
private static final String MASKING_RULE_SEED_SQL = """
|
||||
@@ -707,16 +707,21 @@ public class BackofficeSchemaService {
|
||||
private String sqlclScript(String currentUser) {
|
||||
String owner = currentUser == null || currentUser.isBlank() ? "ADMIN" : currentUser;
|
||||
return """
|
||||
-- sqlcl에서 실행할 HMM VPD runtime 준비 순서
|
||||
-- 1. ADMIN으로 접속
|
||||
@database/adb/25_agent_ords_security_backoffice_support.sql
|
||||
@database/adb/72_hmm_leave_team_vpd.sql
|
||||
-- sqlcl에서 실행할 VPD/ORDS runtime 준비 순서
|
||||
-- 1. ADMIN 또는 보호 객체 owner로 접속
|
||||
@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
|
||||
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||
|
||||
-- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한
|
||||
CONNECT %s/<password>@<tns_alias>
|
||||
GRANT EXECUTE ON hmm_access_ctx_pkg TO <hmm_runtime_user>;
|
||||
GRANT SELECT ON hmm_leave_balances TO <hmm_runtime_user>;
|
||||
GRANT SELECT ON hmm_leave_requests TO <hmm_runtime_user>;
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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;
|
||||
@@ -38,8 +39,15 @@ public class MaskingPolicySynchronizer {
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.mapper = mapper;
|
||||
this.dataCatalog = dataCatalog;
|
||||
this.policyCatalog = policyCatalog;
|
||||
}
|
||||
|
||||
private static Map<String, String> managedPolicyMap() {
|
||||
Map<String, String> policies = new LinkedHashMap<>();
|
||||
policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
|
||||
policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
|
||||
policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
|
||||
policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
|
||||
return Collections.unmodifiableMap(policies);
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
|
||||
@@ -55,7 +55,7 @@ public class MaskingRuleService {
|
||||
return mapper.findColumnRules();
|
||||
}
|
||||
|
||||
/** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */
|
||||
/** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
|
||||
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||
List<MaskingPolicyTarget> policies = maskingPolicySynchronizer.managedPolicies();
|
||||
if (policies.isEmpty()) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
@@ -10,35 +8,29 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */
|
||||
/** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */
|
||||
@Service
|
||||
public class McpSseService {
|
||||
|
||||
private static final String MCP_CALL_PATH = "/mcp (tools/call)";
|
||||
private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
|
||||
private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
|
||||
private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
|
||||
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
||||
SELECT_AI_VPD_QUERY_TOOL,
|
||||
"SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.",
|
||||
-1L,
|
||||
"Smilegate 게임 데이터 Text2SQL",
|
||||
SELECT_AI_VPD_QUERY_PATH
|
||||
);
|
||||
|
||||
private final HmmAiAgentToolRunner agentToolRunner;
|
||||
private final SelectAiService selectAiService;
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||
private final McpToolCatalog toolCatalog;
|
||||
private final McpProperties mcpProperties;
|
||||
private final BackofficeProperties backofficeProperties;
|
||||
private final SmilegateSelectAiService smilegateSelectAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public McpSseService(
|
||||
HmmAiAgentToolRunner agentToolRunner,
|
||||
SelectAiService selectAiService,
|
||||
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||
McpToolCatalog toolCatalog,
|
||||
McpProperties mcpProperties,
|
||||
BackofficeProperties backofficeProperties,
|
||||
SmilegateSelectAiService smilegateSelectAiService,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.agentToolRunner = agentToolRunner;
|
||||
this.selectAiService = selectAiService;
|
||||
this.bearerAuthenticator = bearerAuthenticator;
|
||||
this.toolCatalog = toolCatalog;
|
||||
this.mcpProperties = mcpProperties;
|
||||
this.backofficeProperties = backofficeProperties;
|
||||
this.smilegateSelectAiService = smilegateSelectAiService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -46,57 +38,47 @@ public class McpSseService {
|
||||
return handle(contextPath, request, "");
|
||||
}
|
||||
|
||||
/** Discovery and execution use the same HMM business-user Bearer token boundary. */
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
|
||||
bearerAuthenticator.authenticate(bearerToken);
|
||||
/**
|
||||
* The HTTP bearer token is the business-user subject token; no separate MCP token is used.
|
||||
*/
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) {
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("jsonrpc", "2.0");
|
||||
if (request != null && request.has("id")) {
|
||||
response.set("id", request.get("id"));
|
||||
}
|
||||
|
||||
String method =
|
||||
request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
||||
JsonNode parameters =
|
||||
request == null ? objectMapper.createObjectNode() : request.path("params");
|
||||
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
||||
JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
|
||||
try {
|
||||
response.set("result", switch (method) {
|
||||
case "initialize" -> initializeResult(contextPath);
|
||||
case "notifications/initialized" -> objectMapper.createObjectNode();
|
||||
case "tools/list" -> toolsListResult();
|
||||
case "tools/call" -> toolsCallResult(parameters, bearerToken);
|
||||
case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
|
||||
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
||||
});
|
||||
} catch (McpUnauthorizedException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
} catch (Exception e) {
|
||||
response.remove("result");
|
||||
ObjectNode error = objectMapper.createObjectNode();
|
||||
error.put("code", -32000);
|
||||
error.put("message", safeMessage(exception));
|
||||
error.put("message", e.getMessage());
|
||||
response.set("error", error);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** The only tool registered by this MCP server. */
|
||||
public List<McpToolView> registeredTools() {
|
||||
return toolCatalog.tools().stream()
|
||||
.map(tool -> new McpToolView(
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
-1L,
|
||||
tool.label(),
|
||||
tool.agentTool() ? tool.targetName() : MCP_CALL_PATH
|
||||
))
|
||||
.toList();
|
||||
return List.of(SELECT_AI_VPD_QUERY_VIEW);
|
||||
}
|
||||
|
||||
private ObjectNode initializeResult(String contextPath) {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
result.put("protocolVersion", "2024-11-05");
|
||||
ObjectNode serverInfo = objectMapper.createObjectNode();
|
||||
serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath);
|
||||
serverInfo.put("version", "1.1.0");
|
||||
serverInfo.put("name", "vpd-ords-backoffice-" + contextPath);
|
||||
serverInfo.put("version", "0.1.0");
|
||||
result.set("serverInfo", serverInfo);
|
||||
ObjectNode capabilities = objectMapper.createObjectNode();
|
||||
capabilities.set("tools", objectMapper.createObjectNode());
|
||||
@@ -107,60 +89,58 @@ public class McpSseService {
|
||||
private ObjectNode toolsListResult() {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode tools = objectMapper.createArrayNode();
|
||||
toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool)));
|
||||
tools.add(selectAiVpdQueryTool());
|
||||
result.set("tools", tools);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode toolDefinition(McpToolDefinition tool) {
|
||||
private ObjectNode selectAiVpdQueryTool() {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("name", tool.name());
|
||||
item.put("description", tool.description());
|
||||
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
|
||||
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
|
||||
|
||||
ObjectNode schema = objectMapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
ObjectNode properties = objectMapper.createObjectNode();
|
||||
ObjectNode argument = objectMapper.createObjectNode();
|
||||
argument.put("type", "string");
|
||||
argument.put("description", tool.argumentDescription());
|
||||
argument.put("maxLength", 4000);
|
||||
properties.set(tool.argumentName(), argument);
|
||||
|
||||
ObjectNode prompt = objectMapper.createObjectNode();
|
||||
prompt.put("type", "string");
|
||||
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
|
||||
prompt.put("maxLength", 4000);
|
||||
properties.set("prompt", prompt);
|
||||
|
||||
schema.set("properties", properties);
|
||||
ArrayNode required = objectMapper.createArrayNode();
|
||||
required.add(tool.argumentName());
|
||||
required.add("prompt");
|
||||
schema.set("required", required);
|
||||
schema.put("additionalProperties", false);
|
||||
item.set("inputSchema", schema);
|
||||
return item;
|
||||
}
|
||||
|
||||
private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
|
||||
McpToolDefinition tool = toolCatalog.require(params.path("name").asText(""));
|
||||
String argument = params.path("arguments").path(tool.argumentName()).asText("").trim();
|
||||
if (argument.isBlank()) {
|
||||
throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다.");
|
||||
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
||||
String toolName = params.path("name").asText("");
|
||||
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
|
||||
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
||||
}
|
||||
|
||||
JsonNode toolResponse;
|
||||
if (tool.agentTool()) {
|
||||
ObjectNode input = objectMapper.createObjectNode();
|
||||
input.put(tool.targetParameterName(), argument);
|
||||
toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken);
|
||||
} else {
|
||||
toolResponse = selectAiService.generateAndExecute(bearerToken, argument);
|
||||
JsonNode arguments = params.path("arguments");
|
||||
String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
|
||||
if (token.isBlank()) {
|
||||
return tokenAccessDeniedResult();
|
||||
}
|
||||
JsonNode response;
|
||||
try {
|
||||
response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText(""));
|
||||
} catch (VpdTokenAccessDeniedException ignored) {
|
||||
return tokenAccessDeniedResult();
|
||||
}
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", tool.name());
|
||||
payload.put("executionType", tool.executionType());
|
||||
if (tool.agentTool()) {
|
||||
payload.put("agentTool", tool.targetName());
|
||||
} else {
|
||||
BackofficeProperties.SelectAi selectAi =
|
||||
backofficeProperties == null ? null : backofficeProperties.selectAi();
|
||||
payload.put("profile", selectAi == null || selectAi.profile() == null
|
||||
? "" : selectAi.profile());
|
||||
}
|
||||
payload.set("response", toolResponse);
|
||||
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
|
||||
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
|
||||
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
||||
payload.set("response", response);
|
||||
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode content = objectMapper.createArrayNode();
|
||||
@@ -173,15 +153,26 @@ public class McpSseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message;
|
||||
private ObjectNode tokenAccessDeniedResult() {
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("status", "VPD_TOKEN_DENIED");
|
||||
payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
|
||||
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode content = objectMapper.createArrayNode();
|
||||
ObjectNode text = objectMapper.createObjectNode();
|
||||
text.put("type", "text");
|
||||
text.put("text", pretty(payload));
|
||||
content.add(text);
|
||||
result.set("content", content);
|
||||
result.put("isError", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String pretty(Object value) {
|
||||
try {
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||
} catch (Exception exception) {
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ public class OrdsProbeService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// APEX_JSON omits a ref-cursor column whose value is NULL. The KB
|
||||
// APEX_JSON omits a ref-cursor column whose value is NULL. The
|
||||
// object handlers always select every registered protected column, so
|
||||
// a sensitive column missing from every JSON row is also a NULL result
|
||||
// and must be reported as masked to the MCP/UI caller.
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
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.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Read-only catalogue of security deployment SQL bundled from the Git-tracked
|
||||
* database/adb directory. Script ids are an application whitelist: request input
|
||||
* sql/adb directory. Script ids are an application whitelist: request input
|
||||
* never becomes a filesystem or classpath path.
|
||||
*/
|
||||
@Service
|
||||
public class SecuritySqlScriptService {
|
||||
|
||||
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 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 final List<ScriptDefinition> scripts;
|
||||
|
||||
public SecuritySqlScriptService(
|
||||
SecuritySqlScriptProperties properties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
scripts = parse(properties.scripts(), objectMapper);
|
||||
}
|
||||
|
||||
public List<SecuritySqlScriptSummary> list() {
|
||||
return scripts.stream()
|
||||
return CURATED_SCRIPTS.stream()
|
||||
.map(definition -> new SecuritySqlScriptSummary(
|
||||
definition.scriptId(),
|
||||
definition.category(),
|
||||
@@ -49,7 +47,7 @@ public class SecuritySqlScriptService {
|
||||
}
|
||||
|
||||
public SecuritySqlScript find(String scriptId) {
|
||||
ScriptDefinition definition = scripts.stream()
|
||||
ScriptDefinition definition = CURATED_SCRIPTS.stream()
|
||||
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
||||
@@ -64,7 +62,7 @@ public class SecuritySqlScriptService {
|
||||
}
|
||||
|
||||
private String readSource(String fileName) {
|
||||
ClassPathResource resource = new ClassPathResource("database/adb/" + fileName);
|
||||
ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName);
|
||||
try (InputStream input = resource.getInputStream()) {
|
||||
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException exception) {
|
||||
@@ -72,48 +70,7 @@ public class SecuritySqlScriptService {
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다.");
|
||||
}
|
||||
Set<String> scriptIds = new HashSet<>();
|
||||
Set<String> fileNames = new HashSet<>();
|
||||
parsed.forEach(definition -> {
|
||||
validate(definition);
|
||||
if (!scriptIds.add(definition.scriptId())) {
|
||||
throw new IllegalArgumentException("중복 scriptId");
|
||||
}
|
||||
if (!fileNames.add(definition.fileName())) {
|
||||
throw new IllegalArgumentException("중복 fileName");
|
||||
}
|
||||
});
|
||||
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("보안 SQL 정의가 올바르지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
public record ScriptDefinition(
|
||||
private record ScriptDefinition(
|
||||
String scriptId,
|
||||
String category,
|
||||
String fileName,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.net.URI;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
|
||||
@Service
|
||||
public class SelectAiAgentOrdsService {
|
||||
|
||||
static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
|
||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||
private static final int MAX_LIMIT = 100;
|
||||
|
||||
private final SettingService settingService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SelectAiAgentOrdsService(
|
||||
SettingService settingService,
|
||||
RestTemplate ordsAgentRestTemplate,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.settingService = settingService;
|
||||
this.restTemplate = ordsAgentRestTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility overload for callers of the former SQL-generation tool.
|
||||
* The row-access query endpoint is stateless and therefore ignores conversationId.
|
||||
*/
|
||||
public JsonNode run(String bearerToken, String prompt, String conversationId) {
|
||||
return run(bearerToken, prompt, 50);
|
||||
}
|
||||
|
||||
public JsonNode run(String bearerToken, String prompt, int limit) {
|
||||
String normalizedToken = required(bearerToken, "bearerToken");
|
||||
String normalizedPrompt = required(prompt, "prompt");
|
||||
if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
|
||||
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
||||
}
|
||||
int normalizedLimit = normalizeLimit(limit);
|
||||
String baseUrl = settingService.ordsBaseUrl();
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
throw new AppException("ORDS base URL이 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
ObjectNode requestBody = objectMapper.createObjectNode();
|
||||
requestBody.put("prompt", normalizedPrompt);
|
||||
requestBody.put("limit", normalizedLimit);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(normalizedToken);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString());
|
||||
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
endpoint(baseUrl),
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(requestBody.toString(), headers),
|
||||
String.class
|
||||
);
|
||||
JsonNode body = parse(response.getBody());
|
||||
if (body.hasNonNull("error")) {
|
||||
throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
|
||||
}
|
||||
return body;
|
||||
} catch (HttpStatusCodeException e) {
|
||||
if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
|
||||
|| e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
|
||||
+ ": " + responseError(e.getResponseBodyAsString()));
|
||||
} catch (ResourceAccessException e) {
|
||||
throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private URI endpoint(String baseUrl) {
|
||||
return UriComponentsBuilder.fromUriString(baseUrl)
|
||||
.path(ORDS_PATH)
|
||||
.build()
|
||||
.toUri();
|
||||
}
|
||||
|
||||
private JsonNode parse(String value) {
|
||||
try {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
|
||||
}
|
||||
return objectMapper.readTree(value);
|
||||
} catch (AppException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String responseError(String body) {
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(body);
|
||||
return parsed.path("error").asText(body == null ? "" : body);
|
||||
} catch (Exception ignored) {
|
||||
return body == null ? "" : body;
|
||||
}
|
||||
}
|
||||
|
||||
private String required(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException(name + "은(는) 필수입니다.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private int normalizeLimit(int value) {
|
||||
if (value < 1) {
|
||||
return 50;
|
||||
}
|
||||
return Math.min(value, MAX_LIMIT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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.ObjectNode;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile.
|
||||
* The generated SQL is never executed by this service.
|
||||
*/
|
||||
@Service
|
||||
public class SmilegateSelectAiService {
|
||||
|
||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||
private static final String ACTION_SHOWSQL = "showsql";
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final Clock clock;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SmilegateSelectAiService(
|
||||
BackofficeProperties properties,
|
||||
BearerTokenService bearerTokenService,
|
||||
Clock clock,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.clock = clock;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public JsonNode generateShowSql(String bearerToken, String prompt) {
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||
}
|
||||
|
||||
String generatedSql = generate(selectAi, normalizedPrompt);
|
||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "SHOWSQL");
|
||||
response.put("profile", selectAi.profile());
|
||||
response.put("generatedSql", normalizedSql);
|
||||
response.put("execution", "NOT_EXECUTED");
|
||||
response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요.");
|
||||
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()) {
|
||||
throw new AppException("prompt는 필수입니다.");
|
||||
}
|
||||
if (normalized.length() > MAX_PROMPT_LENGTH) {
|
||||
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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());
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, prompt);
|
||||
statement.setString(2, selectAi.profile());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next() || resultSet.getString(1) == null) {
|
||||
throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
|
||||
}
|
||||
return resultSet.getString(1);
|
||||
}
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String validateReadOnlySql(String generatedSql) {
|
||||
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
||||
if (normalized.startsWith("```")) {
|
||||
int firstLineEnd = normalized.indexOf('\n');
|
||||
int closingFence = normalized.lastIndexOf("```");
|
||||
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
|
||||
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
|
||||
}
|
||||
}
|
||||
normalized = normalized.replaceFirst(";\\s*$", "").trim();
|
||||
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
|
||||
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
|
||||
}
|
||||
if (normalized.contains(";")) {
|
||||
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user