refs #703 #704: finalize smilegate game data poc

This commit is contained in:
devmrko
2026-07-22 15:35:02 +09:00
parent 750bfbab5b
commit 3304b22bc4
42 changed files with 743 additions and 1095 deletions

View File

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

View File

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

View File

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

View File

@@ -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+", "");

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -108,8 +108,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
<tbody>
<tr th:each="status : ${policyStatuses}">
<td><code th:text="${status.targetLabel()}">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"

View File

@@ -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">{
"&lt;tools/list의 argument 이름&gt;": "&lt;조회할 자연어 질문&gt;"
"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": "&lt;tools/list의 name&gt;",
"name": "oracle.select_ai.smilegate_game_text2sql",
"arguments": {
"&lt;required argument&gt;": "조회할 자연어 질문"
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
}
}
}</pre>

View File

@@ -159,8 +159,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
<tbody>
<tr th:each="status : ${maskingPolicyStatuses}">
<td><code th:text="${status.targetLabel()}">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>

View File

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

View File

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

View File

@@ -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 &amp; 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 &amp; 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("선택할 수 없는 정형 데이터 테이블입니다.");
}
}

View File

@@ -35,11 +35,25 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("Data &amp; 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("회수·만료 포함")

View File

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