@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -69,33 +69,9 @@ 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:
|
||||
# Profile-owner connection: SHOWSQL generation only.
|
||||
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}}
|
||||
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:${BACKOFFICE_DB_USERNAME:}}
|
||||
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:${BACKOFFICE_DB_PASSWORD:}}
|
||||
profile: ${BACKOFFICE_SELECT_AI_PROFILE:}
|
||||
# Non-EXEMPT execution boundary: no fallback to the profile owner is allowed.
|
||||
runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:}
|
||||
runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:}
|
||||
runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:}
|
||||
# Customer/project query semantics are external JSON, never Java constants.
|
||||
query-contract-file: ${BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE:}
|
||||
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:
|
||||
public-url: ${BACKOFFICE_MCP_PUBLIC_URL:${BACKOFFICE_HMM_MCP_PUBLIC_URL:/mcp}}
|
||||
server-name: ${BACKOFFICE_MCP_SERVER_NAME:data-ai-backoffice}
|
||||
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을 생성하고 검증 후 실행합니다.}
|
||||
prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.}
|
||||
tools: ${BACKOFFICE_MCP_TOOLS:}
|
||||
masking:
|
||||
policies: ${BACKOFFICE_MASKING_POLICIES:}
|
||||
security-sql-scripts:
|
||||
scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:}
|
||||
# Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
|
||||
# not the ADMIN connection used by the backoffice control plane.
|
||||
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_HAIKU45}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
||||
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
||||
INSERT INTO hmm_access_audit (
|
||||
event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||
INSERT INTO sg_audit_event (
|
||||
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||
) VALUES (
|
||||
sg_audit_event_seq.NEXTVAL,
|
||||
#{eventType,jdbcType=VARCHAR},
|
||||
#{keyId,jdbcType=NUMERIC},
|
||||
#{objectId,jdbcType=NUMERIC},
|
||||
|
||||
@@ -68,20 +68,19 @@
|
||||
-->
|
||||
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
||||
WITH managed_policy AS (
|
||||
<foreach collection="policies" item="policy" separator=" UNION ALL ">
|
||||
SELECT #{policy.objectName} AS object_name,
|
||||
#{policy.policyName} AS policy_name
|
||||
FROM dual
|
||||
</foreach>
|
||||
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
|
||||
),
|
||||
configured AS (
|
||||
SELECT protected_object.object_name,
|
||||
COUNT(*) AS configured_column_count
|
||||
FROM cb_column_masking_rule link
|
||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||
WHERE protected_object.owner = #{owner}
|
||||
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'
|
||||
AND rule.enabled_yn = 'Y'
|
||||
GROUP BY protected_object.object_name
|
||||
),
|
||||
@@ -97,7 +96,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 = #{owner}
|
||||
WHERE policy.object_owner = 'SGMP_POC'
|
||||
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
||||
),
|
||||
missing_columns AS (
|
||||
@@ -111,7 +110,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 = #{owner}
|
||||
WHERE protected_object.owner = 'SGMP_POC'
|
||||
AND rule.enabled_yn = 'Y'
|
||||
AND policy_column.column_name IS NULL
|
||||
GROUP BY protected_object.object_name
|
||||
@@ -121,21 +120,33 @@
|
||||
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 = #{owner}
|
||||
WHERE policy_column.object_owner = 'SGMP_POC'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM cb_column_masking_rule link
|
||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||
WHERE protected_object.owner = #{owner}
|
||||
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'
|
||||
AND protected_object.object_name = policy_column.object_name
|
||||
AND protected_column.column_name = policy_column.column_name
|
||||
AND rule.enabled_yn = 'Y'
|
||||
)
|
||||
GROUP BY policy_column.object_name
|
||||
),
|
||||
legacy_vpd_column_policy AS (
|
||||
SELECT policy.object_name,
|
||||
COUNT(*) AS legacy_vpd_column_policy_count
|
||||
FROM all_policies policy
|
||||
WHERE policy.object_owner = 'SGMP_POC'
|
||||
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 #{owner} AS owner,
|
||||
SELECT 'SGMP_POC' AS owner,
|
||||
managed.object_name,
|
||||
managed.policy_name,
|
||||
database_policy.enable AS enabled,
|
||||
|
||||
@@ -3,31 +3,36 @@
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
|
||||
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
||||
SELECT s.user_id AS stakeholder_user_id,
|
||||
s.user_nm AS username,
|
||||
s.role,
|
||||
s.channel,
|
||||
s.access_scope,
|
||||
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
|
||||
u.user_name AS username,
|
||||
COALESCE((
|
||||
SELECT MIN(r.role_name)
|
||||
FROM sg_user_role user_role
|
||||
JOIN sg_app_role r ON r.role_id = user_role.role_id
|
||||
WHERE user_role.user_id = u.user_id
|
||||
), 'DATA_AI_OPERATOR') AS role,
|
||||
'BACKOFFICE' AS channel,
|
||||
u.dept_code AS access_scope,
|
||||
u.user_id AS app_user_id
|
||||
FROM poc_2.kb_stakeholders s
|
||||
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
|
||||
FROM sg_app_user u
|
||||
WHERE u.active = 'Y'
|
||||
ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END,
|
||||
s.channel,
|
||||
s.user_nm,
|
||||
s.user_id
|
||||
ORDER BY role, u.dept_code, u.user_name, u.user_id
|
||||
</select>
|
||||
|
||||
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
||||
SELECT s.user_id AS stakeholder_user_id,
|
||||
s.user_nm AS username,
|
||||
s.role,
|
||||
s.channel,
|
||||
s.access_scope,
|
||||
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
|
||||
u.user_name AS username,
|
||||
COALESCE((
|
||||
SELECT MIN(r.role_name)
|
||||
FROM sg_user_role user_role
|
||||
JOIN sg_app_role r ON r.role_id = user_role.role_id
|
||||
WHERE user_role.user_id = u.user_id
|
||||
), 'DATA_AI_OPERATOR') AS role,
|
||||
'BACKOFFICE' AS channel,
|
||||
u.dept_code AS access_scope,
|
||||
u.user_id AS app_user_id
|
||||
FROM poc_2.kb_stakeholders s
|
||||
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
|
||||
WHERE s.user_id = #{stakeholderUserId,jdbcType=VARCHAR}
|
||||
FROM sg_app_user u
|
||||
WHERE TO_CHAR(u.user_id) = #{stakeholderUserId,jdbcType=VARCHAR}
|
||||
AND u.active = 'Y'
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
@@ -733,16 +733,16 @@ function permissionRuleBusinessLabel(type, column, value) {
|
||||
return `토큰 이해관계자 본인 행 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'OWN_CONTRACT') {
|
||||
return `담당 설계사 본인 계약 (${displayColumn})`;
|
||||
return `담당 게임 서비스 범위 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'CHANNEL_CONTRACT') {
|
||||
return `토큰 사용자 채널 계약 (${displayColumn})`;
|
||||
return `토큰 채널 게임 서비스 범위 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'OWN_CUSTOMER') {
|
||||
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
||||
return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'CHANNEL_CUSTOMER') {
|
||||
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
||||
return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'STATIC_SQL') {
|
||||
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
|
||||
@@ -823,10 +823,10 @@ function collectWizardPredicates(root) {
|
||||
return `${displayColumn} = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')`;
|
||||
}
|
||||
if (type === 'STAKEHOLDER_SELF') {
|
||||
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`;
|
||||
return '조건 코드 STAKEHOLDER_SELF: DB 행 접근 함수가 토큰 컨텍스트와 등록된 업무 관계를 기준으로 조건을 생성';
|
||||
}
|
||||
if (type === 'STAKEHOLDER_CHANNEL') {
|
||||
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`;
|
||||
return '조건 코드 STAKEHOLDER_CHANNEL: DB 행 접근 함수가 토큰 채널과 등록된 업무 관계를 기준으로 조건을 생성';
|
||||
}
|
||||
if (type === 'TOKEN_SUBJECT') {
|
||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
|
||||
@@ -838,10 +838,10 @@ function collectWizardPredicates(root) {
|
||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
|
||||
}
|
||||
if (type === 'OWN_CUSTOMER') {
|
||||
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'))`;
|
||||
return '조건 코드 OWN_CUSTOMER: DB 행 접근 함수가 담당 사용자와 게임 데이터 관계를 기준으로 조건을 생성';
|
||||
}
|
||||
if (type === 'CHANNEL_CUSTOMER') {
|
||||
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL'))`;
|
||||
return '조건 코드 CHANNEL_CUSTOMER: DB 행 접근 함수가 토큰 채널과 게임 데이터 관계를 기준으로 조건을 생성';
|
||||
}
|
||||
if (type === 'DEPT' || type === 'EMP_NO') {
|
||||
return `${displayColumn} = ${sqlLiteral(value)}`;
|
||||
|
||||
@@ -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()}">OWNER.OBJECT_NAME</code></td>
|
||||
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||
<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 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>
|
||||
@@ -142,17 +142,17 @@
|
||||
|
||||
<section class="content-band">
|
||||
<h2>컬럼 마스킹 규칙 등록</h2>
|
||||
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>EMAIL_STANDARD</code>.</p>
|
||||
<p class="section-subtitle">게임 데이터 기준의 업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>GAME_USER_ID_MASK</code>.</p>
|
||||
<form method="post" action="/masking-rules" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
규칙 코드
|
||||
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="EMAIL_STANDARD">
|
||||
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="GAME_USER_ID_MASK">
|
||||
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
|
||||
</label>
|
||||
<label>
|
||||
규칙명
|
||||
<input class="form-control" name="ruleName" maxlength="100" required placeholder="주민번호 기본 마스킹">
|
||||
<input class="form-control" name="ruleName" maxlength="100" required placeholder="게임 사용자 식별자 기본 마스킹">
|
||||
</label>
|
||||
<label>
|
||||
마스킹 템플릿
|
||||
@@ -176,8 +176,8 @@
|
||||
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="rule : ${rules}">
|
||||
<td><code th:text="${rule.ruleCode()}">EMAIL_STANDARD</code></td>
|
||||
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
|
||||
<td><code th:text="${rule.ruleCode()}">GAME_USER_ID_MASK</code></td>
|
||||
<td th:text="${rule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
||||
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
||||
<td th:text="${rule.description() ?: '-'}">설명</td>
|
||||
<td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</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}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
|
||||
th:text="${object.displayName() + '.' + columnName}">SGMP_POC.COMN_SALES_TXN.GUID</option>
|
||||
</optgroup>
|
||||
</th:block>
|
||||
</select>
|
||||
<span class="form-hint">환경 설정에 관리 대상 ASO 정책이 있는 업무 데이터 객체만 표시됩니다. 예: <code>OWNER.OBJECT_NAME.COLUMN_NAME</code>.</span>
|
||||
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 게임 데이터 객체만 표시됩니다. 예: <code>SGMP_POC.COMN_SALES_TXN.GUID</code>.</span>
|
||||
</label>
|
||||
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
||||
</form>
|
||||
@@ -253,9 +253,9 @@
|
||||
<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()}">ADMIN.HMM_HR_EMPLOYEES.EMAIL</code></td>
|
||||
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td>
|
||||
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</td>
|
||||
<td><code th:text="${columnRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.USER_ID</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>
|
||||
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
|
||||
<span class="badge"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>MCP 연동</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>환경 설정으로 승인한 MCP 도구만 제공합니다. <code>tools/list</code>의 설명과 입력 스키마를 확인한 뒤 사용자 Bearer Token으로 호출하세요.</p>
|
||||
<p>사용자 Bearer Token을 검증한 뒤 <code>SGMP_POC_HAIKU45</code> Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
<td><code th:text="${tool.ordsPath()}">AGENT_TOOL_OR_SELECT_AI</code></td>
|
||||
<td>
|
||||
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
||||
<small class="text-muted">정확한 argument 이름과 필수 여부는 <code>tools/list.inputSchema</code>를 사용합니다.</small>
|
||||
<small class="text-muted">
|
||||
HTTP <code>Authorization</code> → 활성 PoC 사용자 토큰 검증 · <code>prompt</code> → 게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(tools)}">
|
||||
@@ -106,9 +108,9 @@
|
||||
<summary>tools/call parameter 예시 보기</summary>
|
||||
<h2>tools/call Arguments</h2>
|
||||
<pre class="code-block">{
|
||||
"<tools/list의 argument 이름>": "<조회할 자연어 질문>"
|
||||
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
|
||||
}</pre>
|
||||
<p class="form-hint">도구명, 설명, argument 이름은 배포 환경에서 바뀔 수 있으므로 <code>tools/list</code> 결과를 기준으로 호출합니다.</p>
|
||||
<p class="form-hint">등록 tool은 <code>oracle.select_ai.smilegate_game_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 <code>SGMP_POC_HAIKU45</code>가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -142,9 +144,9 @@
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "<tools/list의 name>",
|
||||
"name": "oracle.select_ai.smilegate_game_text2sql",
|
||||
"arguments": {
|
||||
"<required argument>": "조회할 자연어 질문"
|
||||
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 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()}">OWNER.OBJECT_NAME</code></td>
|
||||
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||
<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 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>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<h1>행 접근 규칙</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. HMM 휴가 원장의 저장값은 <code>HMM_LEAVE_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꿉니다.</p>
|
||||
<p>팀장은 본인과 직속 팀원, 팀원은 본인 행만 조회하도록 설정합니다. 컬럼 원문/마스킹은 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 별도로 관리합니다.</p>
|
||||
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
|
||||
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 게임 사용자 식별자, 결제금액 같은 민감 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -157,14 +157,19 @@
|
||||
<option value="">객체 컬럼 선택</option>
|
||||
</select>
|
||||
<select class="form-select rule-type-select" name="ruleType">
|
||||
<optgroup label="HMM HR · 표준 범위">
|
||||
<option value="SELF">본인 직원 행</option>
|
||||
<option value="MANAGED_TEAM">본인 및 직접 보고 팀원</option>
|
||||
<option value="ALL">전체 행</option>
|
||||
<option value="ALL">ALL</option>
|
||||
<optgroup label="게임 데이터 · 담당자 범위">
|
||||
<option value="OWN_CONTRACT">담당 게임 서비스</option>
|
||||
<option value="OWN_CUSTOMER">담당 게임 사용자</option>
|
||||
</optgroup>
|
||||
<optgroup label="고급 조건">
|
||||
<option value="=">선택 컬럼 값 일치</option>
|
||||
<option value="!=">선택 컬럼 값 불일치</option>
|
||||
<optgroup label="게임 데이터 · 채널 범위">
|
||||
<option value="CHANNEL_CONTRACT">토큰 채널 게임 서비스</option>
|
||||
<option value="CHANNEL_CUSTOMER">토큰 채널 게임 사용자</option>
|
||||
</optgroup>
|
||||
<optgroup label="게임 데이터 · 토큰 식별">
|
||||
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
|
||||
</optgroup>
|
||||
<optgroup label="SQL · 정적 조건">
|
||||
<option value="STATIC_SQL">정적 SQL 조건식</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
@@ -174,7 +179,7 @@
|
||||
</div>
|
||||
<details class="explanation-details">
|
||||
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
|
||||
<p class="wizard-hint"><strong>표준 범위</strong>는 유효한 HMM 토큰에서 만든 직원 context로 치환됩니다. <code>SELF</code>는 현재 직원의 행만, <code>MANAGED_TEAM</code>은 현재 직원과 <code>MANAGER_EMPLOYEE_ID</code>로 연결된 직접 보고자의 행만 남깁니다. <strong>정적 SQL 조건식</strong>은 현재 객체 컬럼을 사용한 고정 WHERE 조건을 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.</p>
|
||||
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context와 DB에 등록된 업무 관계를 바탕으로 행 접근 함수가 해석합니다. 관계가 없는 게임 데이터 객체에는 조건 코드를 억지로 적용하지 말고, <strong>정적 SQL 조건식</strong>으로 <code>GAME_ID = 'CZN'</code>처럼 실제 컬럼을 사용하세요. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.</p>
|
||||
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
|
||||
</details>
|
||||
<details class="explanation-details">
|
||||
@@ -190,19 +195,29 @@
|
||||
<td><code>1 = 1</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SELF</code></td>
|
||||
<td>토큰으로 식별된 HMM 직원 본인 행</td>
|
||||
<td><code>EMPLOYEE_ID = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')</code></td>
|
||||
<td><code>TOKEN_SUBJECT</code></td>
|
||||
<td>토큰으로 식별된 운영 사용자 범위</td>
|
||||
<td>대상 객체가 토큰 주체 식별 컬럼을 가질 때만 DB 행 접근 함수가 생성</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>MANAGED_TEAM</code></td>
|
||||
<td>팀장 본인과 직접 보고 팀원의 행</td>
|
||||
<td><code>EMPLOYEE_ID IN (현재 직원 및 MANAGER_EMPLOYEE_ID가 현재 직원인 직원)</code></td>
|
||||
<td><code>OWN_CONTRACT</code></td>
|
||||
<td>토큰 사용자가 담당하는 게임 서비스 범위</td>
|
||||
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>CHANNEL_CONTRACT</code></td>
|
||||
<td>토큰 채널에 속한 게임 서비스 범위</td>
|
||||
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
|
||||
<td>게임 서비스·사용자 기준으로 연결되는 게임 로그·판매·환불 데이터 범위</td>
|
||||
<td>대상 테이블의 실제 키 관계를 DB 행 접근 함수가 검증한 뒤 생성</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>STATIC_SQL</code></td>
|
||||
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
|
||||
<td><code>REQUEST_STATUS = 'PENDING'</code>처럼 검증된 현재 객체 컬럼 조건</td>
|
||||
<td><code>GAME_ID = 'CZN'</code>처럼 검증된 현재 객체 컬럼 조건</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
<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()}">62_kb_aso_masking_backoffice_metadata.sql</code></td>
|
||||
<td><code th:text="${item.fileName()}">71_sg_identity_administration.sql</code></td>
|
||||
<td>
|
||||
<strong th:text="${item.title()}">컬럼 마스킹 규칙 메타데이터</strong>
|
||||
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
|
||||
<div class="form-hint" th:text="${item.description()}">설명</div>
|
||||
</td>
|
||||
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td>
|
||||
@@ -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()}">ASO / 마스킹</span>
|
||||
<h2 class="mt-2" th:text="${selectedScript.title()}">컬럼 마스킹 규칙 메타데이터</h2>
|
||||
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">Smilegate 권한</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()}">62_kb_aso_masking_backoffice_metadata.sql</code>
|
||||
<code th:text="${selectedScript.fileName()}">71_sg_identity_administration.sql</code>
|
||||
</div>
|
||||
<p class="form-hint">Git source: <code th:text="${'database/adb/' + selectedScript.fileName()}">database/adb/62_kb_aso_masking_backoffice_metadata.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/71_sg_identity_administration.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()}">
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('HMM 액세스 토큰')}"></head>
|
||||
<head th:replace="~{fragments/layout :: head('Smilegate 액세스 토큰')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>HMM 액세스 토큰</h1>
|
||||
<h1>Smilegate 액세스 토큰</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
||||
<p>Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>직원 토큰 발급</h2>
|
||||
<p class="section-subtitle">활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
|
||||
<h2>도구 사용자 토큰 발급</h2>
|
||||
<p class="section-subtitle">활성 Data & AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/tokens" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
HMM 직원
|
||||
도구 사용자
|
||||
<select class="form-select" name="userId" required>
|
||||
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
|
||||
<option th:each="user : ${users}"
|
||||
@@ -48,7 +48,7 @@
|
||||
</label>
|
||||
<label>
|
||||
용도 메모
|
||||
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
|
||||
<input class="form-control" name="description" maxlength="200" placeholder="예: 게임 데이터 AI 질의 검증">
|
||||
</label>
|
||||
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
|
||||
</form>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<select class="form-select" name="userId" required>
|
||||
<option value="">선택하세요</option>
|
||||
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
|
||||
th:text="${user.username() + ' (' + user.empNo() + ')'}">demo-user</option>
|
||||
th:text="${user.username() + ' (' + user.empNo() + ')'}">sg-teamlead (SG-001)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -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()}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
|
||||
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</option>
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
@@ -85,9 +85,9 @@
|
||||
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="userRule : ${userRules}">
|
||||
<td th:text="${userRule.username()}">demo-user</td>
|
||||
<td><code th:text="${userRule.targetLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</code></td>
|
||||
<td th:text="${userRule.ruleLabel()}">주민번호 기본 마스킹 · 주민등록번호 부분 마스킹</td>
|
||||
<td th:text="${userRule.username()}">sg-teamlead</td>
|
||||
<td><code th:text="${userRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</code></td>
|
||||
<td 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>
|
||||
<td>
|
||||
|
||||
@@ -82,8 +82,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="policy : ${policies}">
|
||||
<td><code th:text="${policy.objectDisplayName()}">OWNER.OBJECT_NAME</code></td>
|
||||
<td><code th:text="${policy.policyName()}">ROW_ACCESS_POLICY</code></td>
|
||||
<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 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>
|
||||
@@ -105,7 +105,7 @@
|
||||
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 DB 패키지에서 해시·만료·회수·재직 상태를 검증합니다.</td><td><code>HMM_ACCESS_CTX_PKG</code></td></tr>
|
||||
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
|
||||
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
|
||||
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 개인정보 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
|
||||
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 게임 사용자 식별자·거래 식별자의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class MaskingPolicySynchronizerTest {
|
||||
|
||||
@@ -29,29 +23,8 @@ class MaskingPolicySynchronizerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void hmmEmployeePolicyComesFromEnvironmentPolicyCatalog() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
var dataCatalog = new EnvironmentDataCatalog(
|
||||
new CatalogProperties("ADMIN", """
|
||||
[{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
|
||||
"businessName":"직원","description":"직원"}]
|
||||
"""),
|
||||
objectMapper);
|
||||
var policyCatalog = new EnvironmentMaskingPolicyCatalog(
|
||||
new MaskingProperties("""
|
||||
[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"}]
|
||||
"""),
|
||||
objectMapper);
|
||||
var synchronizer = new MaskingPolicySynchronizer(
|
||||
mock(JdbcTemplate.class),
|
||||
mock(MaskingRuleMapper.class),
|
||||
dataCatalog,
|
||||
policyCatalog);
|
||||
|
||||
assertThat(synchronizer.owner()).isEqualTo("ADMIN");
|
||||
assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES"))
|
||||
.isEqualTo("HMM_EMPLOYEE_PII_REDACT");
|
||||
assertThat(synchronizer.managedObjectNames())
|
||||
.doesNotContain("KB_CUSTOMERS", "KB_CLAIMS", "KB_CONTRACTS");
|
||||
void smilegateSalesTransactionPolicyIsManagedByBackoffice() {
|
||||
assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN"))
|
||||
.isEqualTo("SG_SALES_TXN_REDACT");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +1,71 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class McpSseServiceTest {
|
||||
|
||||
private static final String HMM_TOOLS = """
|
||||
[
|
||||
{
|
||||
"name":"resolve_hr_term",
|
||||
"label":"HMM HR 용어 표준화",
|
||||
"description":"휴가·근태 표현을 표준 용어와 코드로 변환합니다.",
|
||||
"argumentName":"term",
|
||||
"argumentDescription":"확인할 휴가·근태 용어입니다.",
|
||||
"executionType":"AGENT_TOOL",
|
||||
"targetName":"HMM_HR_TERM_RESOLVER",
|
||||
"targetParameterName":"P_TERM"
|
||||
},
|
||||
{
|
||||
"name":"search_hr_data",
|
||||
"label":"HMM HR 데이터 조회",
|
||||
"description":"조직, 직원, 휴가, 근태 데이터를 조회합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"완전한 자연어 질문입니다.",
|
||||
"executionType":"AGENT_TOOL",
|
||||
"targetName":"HMM_HR_NORMALIZED_DATA_SEARCH",
|
||||
"targetParameterName":"P_QUERY"
|
||||
},
|
||||
{
|
||||
"name":"search_hr_policy",
|
||||
"label":"HMM HR 규정 검색",
|
||||
"description":"HR 규정 PDF를 검색합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"정책에 대한 자연어 질문입니다.",
|
||||
"executionType":"AGENT_TOOL",
|
||||
"targetName":"HMM_HR_POLICY_SEARCH",
|
||||
"targetParameterName":"P_QUERY"
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final McpProperties mcpProperties =
|
||||
new McpProperties(
|
||||
"https://example.com/mcp",
|
||||
"hmm-hr-backoffice",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
HMM_TOOLS);
|
||||
private final McpToolCatalog toolCatalog =
|
||||
new EnvironmentMcpToolCatalog(mcpProperties, objectMapper);
|
||||
private final CapturingHmmAiAgentToolRunner agentToolRunner =
|
||||
new CapturingHmmAiAgentToolRunner();
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator =
|
||||
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
|
||||
private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService();
|
||||
private final McpSseService service = new McpSseService(
|
||||
agentToolRunner,
|
||||
mock(SelectAiService.class),
|
||||
bearerAuthenticator,
|
||||
toolCatalog,
|
||||
mcpProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
selectAiService,
|
||||
objectMapper
|
||||
);
|
||||
|
||||
@Test
|
||||
void listsEnvironmentConfiguredTermDataAndPolicyTools() {
|
||||
ObjectNode response =
|
||||
service.handle("default", request(1, "tools/list"), "valid-token");
|
||||
void listsOnlyVpdSelectAiToolWithPromptInput() {
|
||||
ObjectNode response = service.handle("default", request(1, "tools/list"));
|
||||
|
||||
var tools = response.path("result").path("tools");
|
||||
assertThat(tools).hasSize(3);
|
||||
assertThat(tools).extracting(node -> node.path("name").asText())
|
||||
.containsExactly("resolve_hr_term", "search_hr_data", "search_hr_policy");
|
||||
assertThat(tools.get(0).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("term");
|
||||
assertThat(tools.get(1).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("query");
|
||||
assertThat(tools).hasSize(1);
|
||||
var selectAi = tools.get(0);
|
||||
assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql");
|
||||
assertThat(selectAi.path("inputSchema").path("required"))
|
||||
.extracting(node -> node.asText())
|
||||
.contains("prompt");
|
||||
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
|
||||
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse();
|
||||
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsConfiguredAgentToolWithItsDeclaredInputName() {
|
||||
void callsVpdSelectAiThroughOrdsService() {
|
||||
ObjectNode request = request(2, "tools/call");
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "resolve_hr_term");
|
||||
params.putObject("arguments").put("term", "연차 이월");
|
||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
|
||||
ObjectNode arguments = params.putObject("arguments");
|
||||
arguments.put("prompt", "카제나 AU를 조회해 줘");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "valid-token");
|
||||
ObjectNode response = service.handle("default", request, "user-bearer");
|
||||
|
||||
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
|
||||
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
|
||||
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
|
||||
CapturingSmilegateSelectAiService agentService =
|
||||
(CapturingSmilegateSelectAiService) selectAiService;
|
||||
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
||||
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
|
||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesSelectAiToolThroughTheVpdAwareExecutor() {
|
||||
String selectAiTools = """
|
||||
[
|
||||
{
|
||||
"name":"search_hr_data",
|
||||
"label":"HMM HR 데이터 조회",
|
||||
"description":"VPD가 적용된 HMM HR 데이터를 조회합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"완전한 자연어 질문입니다.",
|
||||
"executionType":"SELECT_AI"
|
||||
}
|
||||
]
|
||||
""";
|
||||
McpProperties selectAiProperties =
|
||||
new McpProperties("", "", "", "", "", "", selectAiTools);
|
||||
SelectAiService selectAiService = mock(SelectAiService.class);
|
||||
when(selectAiService.generateAndExecute(
|
||||
"valid-token", "내 휴가 신청 내역을 보여줘"))
|
||||
.thenReturn(objectMapper.createObjectNode().put("vpdEnforced", true));
|
||||
McpSseService selectAiMcp = new McpSseService(
|
||||
agentToolRunner,
|
||||
selectAiService,
|
||||
bearerAuthenticator,
|
||||
new EnvironmentMcpToolCatalog(selectAiProperties, objectMapper),
|
||||
selectAiProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
ObjectNode request = request(5, "tools/call");
|
||||
request.putObject("params")
|
||||
.put("name", "search_hr_data")
|
||||
.putObject("arguments")
|
||||
.put("query", "내 휴가 신청 내역을 보여줘");
|
||||
|
||||
ObjectNode response =
|
||||
selectAiMcp.handle("default", request, "valid-token");
|
||||
|
||||
verify(selectAiService).generateAndExecute(
|
||||
"valid-token", "내 휴가 신청 내역을 보여줘");
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||
.contains("\"vpdEnforced\" : true");
|
||||
.contains("SGMP_POC_HAIKU45")
|
||||
.contains("SELECT 1 FROM DUAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||
McpSseService rejectingService = new McpSseService(
|
||||
agentToolRunner,
|
||||
mock(SelectAiService.class),
|
||||
token -> {
|
||||
throw new McpUnauthorizedException();
|
||||
},
|
||||
toolCatalog,
|
||||
mcpProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
|
||||
.isInstanceOf(McpUnauthorizedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownToolsWithoutCallingTheRunner() {
|
||||
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
|
||||
ObjectNode request = request(3, "tools/call");
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "unconfigured.tool");
|
||||
params.putObject("arguments").put("prompt", "query");
|
||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
|
||||
params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "valid-token");
|
||||
ObjectNode response = service.handle("default", request, "");
|
||||
|
||||
assertThat(response.path("result").isMissingNode()).isTrue();
|
||||
assertThat(response.path("error").path("message").asText())
|
||||
.contains("등록되지 않은 MCP tool");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateToolNamesAtStartup() {
|
||||
String duplicate = """
|
||||
[
|
||||
{"name":"same","label":"A","description":"A","argumentName":"query",
|
||||
"argumentDescription":"A","executionType":"AGENT_TOOL",
|
||||
"targetName":"TOOL_A","targetParameterName":"P_QUERY"},
|
||||
{"name":"same","label":"B","description":"B","argumentName":"query",
|
||||
"argumentDescription":"B","executionType":"AGENT_TOOL",
|
||||
"targetName":"TOOL_B","targetParameterName":"P_QUERY"}
|
||||
]
|
||||
""";
|
||||
McpProperties duplicateProperties =
|
||||
new McpProperties("", "", "", "", "", "", duplicate);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
new EnvironmentMcpToolCatalog(duplicateProperties, objectMapper))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("BACKOFFICE_MCP_TOOLS");
|
||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isTrue();
|
||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||
.contains("VPD_TOKEN_DENIED")
|
||||
.contains("권한이 없습니다");
|
||||
}
|
||||
|
||||
private ObjectNode request(int id, String method) {
|
||||
@@ -213,23 +76,22 @@ class McpSseServiceTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private final class CapturingHmmAiAgentToolRunner
|
||||
implements HmmAiAgentToolRunner {
|
||||
private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService {
|
||||
|
||||
private String toolName;
|
||||
private ObjectNode input;
|
||||
private String bearerToken;
|
||||
private String prompt;
|
||||
|
||||
private CapturingSmilegateSelectAiService() {
|
||||
super(null, null, null, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode run(
|
||||
String requestedToolName,
|
||||
ObjectNode requestedInput,
|
||||
String bearerToken
|
||||
) {
|
||||
toolName = requestedToolName;
|
||||
input = requestedInput.deepCopy();
|
||||
public JsonNode generateShowSql(String bearerToken, String prompt) {
|
||||
this.bearerToken = bearerToken;
|
||||
return objectMapper.createObjectNode().put("status", "ok");
|
||||
this.prompt = prompt;
|
||||
return new ObjectMapper().createObjectNode()
|
||||
.put("profile", "SGMP_POC_HAIKU45")
|
||||
.put("generatedSql", "SELECT 1 FROM DUAL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,51 +3,30 @@ package com.cloudhandson.vpdbackoffice.service;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SecuritySqlScriptServiceTest {
|
||||
|
||||
private final SecuritySqlScriptService service = new SecuritySqlScriptService(
|
||||
new SecuritySqlScriptProperties("""
|
||||
[{
|
||||
"scriptId":"hmm-leave-vpd",
|
||||
"category":"HMM / VPD",
|
||||
"fileName":"72_hmm_leave_team_vpd.sql",
|
||||
"title":"HMM 휴가 팀 접근 정책",
|
||||
"description":"팀장과 팀원 휴가 행 접근 정책"
|
||||
}]
|
||||
"""),
|
||||
new ObjectMapper());
|
||||
private final SecuritySqlScriptService service = new SecuritySqlScriptService();
|
||||
|
||||
@Test
|
||||
void exposesOnlyEnvironmentAllowlistedBundledScripts() {
|
||||
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
|
||||
assertThat(service.list())
|
||||
.extracting(item -> item.fileName())
|
||||
.containsExactly("72_hmm_leave_team_vpd.sql");
|
||||
.containsExactly(
|
||||
"70_sg_tool_user.sql",
|
||||
"71_sg_identity_administration.sql"
|
||||
);
|
||||
|
||||
assertThat(service.find("hmm-leave-vpd").source())
|
||||
.contains("HMM_ACCESS_CTX_PKG")
|
||||
.contains("HMM_LEAVE_VPD_FILTER");
|
||||
assertThat(service.find("smilegate-identity-administration").source())
|
||||
.contains("create table sg_app_user")
|
||||
.contains("DATA_AI_TF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownScriptIdsInsteadOfResolvingARequestPath() {
|
||||
void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() {
|
||||
assertThatThrownBy(() -> service.find("../../etc/passwd"))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("조회할 수 없는");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsafeResourcePathsAtStartup() {
|
||||
assertThatThrownBy(() -> new SecuritySqlScriptService(
|
||||
new SecuritySqlScriptProperties("""
|
||||
[{"scriptId":"bad","category":"x","fileName":"../../etc/passwd.sql",
|
||||
"title":"x","description":"x"}]
|
||||
"""),
|
||||
new ObjectMapper()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("BACKOFFICE_SECURITY_SQL_SCRIPTS");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,72 +4,31 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class StructuredDataServiceTest {
|
||||
|
||||
private static final String HMM_OBJECTS = """
|
||||
[
|
||||
{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE",
|
||||
"businessName":"조직 원장","description":"조직 정보"},
|
||||
{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
|
||||
"businessName":"직원 원장","description":"직원 정보"},
|
||||
{"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"VIEW",
|
||||
"businessName":"휴가 잔여 원장","description":"휴가 잔여 정보"}
|
||||
]
|
||||
""";
|
||||
|
||||
private final DataCatalog catalog = new EnvironmentDataCatalog(
|
||||
new CatalogProperties("ADMIN", HMM_OBJECTS), new ObjectMapper());
|
||||
private final StructuredDataService service =
|
||||
new StructuredDataService(mock(JdbcTemplate.class), catalog);
|
||||
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class));
|
||||
|
||||
@Test
|
||||
void exposesOnlyEnvironmentConfiguredTablesAndViews() {
|
||||
assertThat(service.owner()).isEqualTo("ADMIN");
|
||||
void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
|
||||
assertThat(service.tables())
|
||||
.extracting(table -> table.tableName())
|
||||
.containsExactly(
|
||||
"HMM_ORG_TEAMS",
|
||||
"HMM_HR_EMPLOYEES",
|
||||
"HMM_LEAVE_BALANCES");
|
||||
assertThat(service.requireTable("leave-balances").objectType()).isEqualTo("VIEW");
|
||||
assertThat(service.previewSql(service.requireTable("employees")))
|
||||
.isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?");
|
||||
"CZN_COMN_USER_MST",
|
||||
"CZN_COMN_CHARACTER_MST",
|
||||
"COMN_SALES_TXN",
|
||||
"COMN_REFUND_TXN",
|
||||
"COMN_SALES_PRODUCT_DISP_BAS",
|
||||
"COMN_GAME_SERVER_BAS",
|
||||
"COMN_GAME_ALIAS_BAS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnyObjectOutsideTheServerSideAllowlist() {
|
||||
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
|
||||
void rejectsAnyTableOutsideTheServerSideAllowlist() {
|
||||
assertThatThrownBy(() -> service.requireTable("security-audit-log"))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsafeOracleIdentifiers() {
|
||||
assertThatThrownBy(() -> new EnvironmentDataCatalog(
|
||||
new CatalogProperties("ADMIN; DROP USER X", HMM_OBJECTS), new ObjectMapper()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("BACKOFFICE_CATALOG_OWNER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateObjectNamesEvenWhenKeysDiffer() {
|
||||
String duplicate = """
|
||||
[
|
||||
{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
|
||||
"businessName":"직원","description":"직원"},
|
||||
{"key":"workers","tableName":"HMM_HR_EMPLOYEES","objectType":"VIEW",
|
||||
"businessName":"직원 뷰","description":"직원 뷰"}
|
||||
]
|
||||
""";
|
||||
|
||||
assertThatThrownBy(() -> new EnvironmentDataCatalog(
|
||||
new CatalogProperties("ADMIN", duplicate), new ObjectMapper()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS");
|
||||
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,25 @@ class GuidedFlowTemplateTest {
|
||||
.doesNotContain("dashboard-workflow")
|
||||
.doesNotContain(">01<", ">02<");
|
||||
assertThat(layout)
|
||||
.contains("Data & AI Backoffice")
|
||||
.contains("사용자")
|
||||
.contains("접근 그룹")
|
||||
.contains("역할")
|
||||
.contains("토큰")
|
||||
.contains("운영 사용자 관리")
|
||||
.contains("접근 제어")
|
||||
.contains("보호·검증")
|
||||
.contains("연동 도구")
|
||||
.contains("운영")
|
||||
.contains("관리자")
|
||||
.contains("조회 연동")
|
||||
.contains("정형 데이터 조회")
|
||||
.contains("MCP 서비스")
|
||||
.contains("컬럼 마스킹")
|
||||
.contains("컬럼 원문 표시 허용")
|
||||
.contains("행 접근 필터 구조")
|
||||
.contains("보안 SQL 스크립트")
|
||||
.contains("고급 접근 조건")
|
||||
.contains("시스템 설정")
|
||||
.contains("DB 준비 상태")
|
||||
.contains("data-submenu-trigger")
|
||||
.contains("data-submenu-bar")
|
||||
.contains("data-submenu-panel")
|
||||
.contains("backoffice-can-mutate")
|
||||
.contains("backoffice-read-only")
|
||||
.doesNotContain("VPD Backoffice");
|
||||
@@ -108,7 +122,7 @@ class GuidedFlowTemplateTest {
|
||||
.contains("data-wizard-target=\"2\">대상")
|
||||
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
|
||||
assertThat(tokens)
|
||||
.contains("직원 토큰 발급", "검증 세션 발급")
|
||||
.contains("도구 사용자 토큰 발급", "검증 세션 발급")
|
||||
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
|
||||
assertThat(probe)
|
||||
.contains("접근 검증 실행", "<strong>검증 결과</strong>")
|
||||
@@ -184,8 +198,7 @@ class GuidedFlowTemplateTest {
|
||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||
|
||||
assertThat(html)
|
||||
.contains("value=\"MANAGED_TEAM\">본인 및 직접 보고 팀원")
|
||||
.contains("value=\"SELF\">본인 직원 행")
|
||||
.contains("value=\"OWN_CUSTOMER\">담당 게임 사용자")
|
||||
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
|
||||
.contains("HMM_LEAVE_VPD_FILTER")
|
||||
.contains("행 규칙의 두 가지 적용 방식 보기")
|
||||
@@ -209,6 +222,7 @@ class GuidedFlowTemplateTest {
|
||||
String filters = template("vpd-filter-policies.html");
|
||||
|
||||
assertThat(roles)
|
||||
.contains("스마일게이트 Data & AI PoC 운영 역할")
|
||||
.contains("데이터 취급 등급");
|
||||
assertThat(tokens)
|
||||
.contains("회수·만료 포함")
|
||||
|
||||
@@ -31,26 +31,26 @@ class MaskingRuleTemplateRenderTest {
|
||||
var engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
|
||||
var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y");
|
||||
var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y");
|
||||
var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY");
|
||||
var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y");
|
||||
var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED",
|
||||
1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y");
|
||||
var object = new ProtectedObject(7L, "SGMP_POC", "CZN_COMN_USER_MST", "sgmp-poc/czn-users", "Y");
|
||||
var transactionObject = new ProtectedObject(8L, "SGMP_POC", "COMN_SALES_TXN", "sgmp-poc/sales", "Y");
|
||||
var column = new ProtectedColumn(11L, 7L, "GUID", "Y", null, "RESTRICTED", "NULLIFY");
|
||||
var rule = new MaskingRule(1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "테스트", "Y");
|
||||
var columnRule = new ColumnMaskingRule(11L, 7L, "SGMP_POC", "CZN_COMN_USER_MST", "GUID",
|
||||
1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "Y");
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||
context.setVariable("templates", List.of(MaskingTemplate.values()));
|
||||
context.setVariable("rules", List.of(rule));
|
||||
context.setVariable("columnRules", List.of(columnRule));
|
||||
var status = new MaskingPolicyStatus(
|
||||
"POC_2", "KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT", "YES", 1, 1, 0, 0
|
||||
"SGMP_POC", "CZN_COMN_USER_MST", "SG_CZN_USER_REDACT", "YES", 1, 1, 0, 0
|
||||
);
|
||||
context.setVariable("policyStatuses", List.of(status));
|
||||
context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status));
|
||||
context.setVariable("objects", List.of(object, contractObject));
|
||||
context.setVariable("maskingTargetObjects", List.of(object, contractObject));
|
||||
context.setVariable("policyStatusByObjectName", Map.of("CZN_COMN_USER_MST", status));
|
||||
context.setVariable("objects", List.of(object, transactionObject));
|
||||
context.setVariable("maskingTargetObjects", List.of(object, transactionObject));
|
||||
context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of()));
|
||||
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("CUST_NM"), 8L, List.of("PREMIUM")));
|
||||
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("GUID"), 8L, List.of("GUID")));
|
||||
|
||||
String rulesPage = engine.process("masking-rules", context);
|
||||
|
||||
@@ -64,13 +64,13 @@ class MaskingRuleTemplateRenderTest {
|
||||
.contains("DB Redaction 컬럼")
|
||||
.contains("레거시 VPD 컬럼 제어")
|
||||
.contains("마스킹 대상 컬럼 추가")
|
||||
.contains("POC_2.KB_CONTRACTS.PREMIUM")
|
||||
.contains("KB_CUSTOMER_PII_REDACT")
|
||||
.contains("SGMP_POC.COMN_SALES_TXN.GUID")
|
||||
.contains("SG_CZN_USER_REDACT")
|
||||
.contains("적용됨");
|
||||
|
||||
context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y")));
|
||||
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L,
|
||||
"POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y")));
|
||||
context.setVariable("users", List.of(new AppUser(2L, "sg-teamlead", "SG-001", "DATA_AI", "N", "Y")));
|
||||
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "sg-teamlead", 11L,
|
||||
"SGMP_POC", "CZN_COMN_USER_MST", "GUID", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "UNMASK", "Y")));
|
||||
String userPage = engine.process("user-masking-rules", context);
|
||||
|
||||
assertThat(userPage)
|
||||
|
||||
Reference in New Issue
Block a user