refs #740: enforce HMM MCP VPD runtime boundary
This commit is contained in:
@@ -79,7 +79,10 @@ public record BackofficeProperties(
|
||||
String dbUrl,
|
||||
String dbUsername,
|
||||
String dbPassword,
|
||||
String profile
|
||||
String profile,
|
||||
String runtimeDbUrl,
|
||||
String runtimeDbUsername,
|
||||
String runtimeDbPassword
|
||||
) {
|
||||
|
||||
public boolean configured() {
|
||||
@@ -88,5 +91,12 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -45,7 +46,7 @@ public class SelectAiService {
|
||||
}
|
||||
|
||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||
bearerAuthenticator.authenticate(bearerToken);
|
||||
HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi =
|
||||
properties == null ? null : properties.selectAi();
|
||||
@@ -54,11 +55,18 @@ public class SelectAiService {
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요.");
|
||||
}
|
||||
if (!selectAi.runtimeConfigured()) {
|
||||
throw new AppException("VPD 런타임 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_URL, "
|
||||
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD를 확인하세요.");
|
||||
}
|
||||
|
||||
String generatedSql = generate(selectAi, normalizedPrompt);
|
||||
String generatedSql = generate(
|
||||
selectAi, vpdAwarePrompt(normalizedPrompt, principal));
|
||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||
QueryExecution execution = executeReadOnly(
|
||||
selectAi, normalizedSql, bearerToken.trim());
|
||||
selectAi, normalizedSql, bearerToken.trim(), principal);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||
response.put("profile", selectAi.profile());
|
||||
@@ -66,6 +74,8 @@ public class SelectAiService {
|
||||
response.put("execution", "READ_ONLY_EXECUTED");
|
||||
response.put("rowCount", execution.items().size());
|
||||
response.put("truncated", execution.truncated());
|
||||
response.put("vpdEnforced", true);
|
||||
response.put("scopeEmployeeCode", principal.employeeCode());
|
||||
response.set("items", execution.items());
|
||||
response.put("nextStep", execution.truncated()
|
||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다."
|
||||
@@ -84,6 +94,18 @@ public class SelectAiService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String vpdAwarePrompt(String prompt, HmmMcpPrincipal principal) {
|
||||
return """
|
||||
Oracle SQL 생성 규칙:
|
||||
- 프로필에 승인된 HMM HR 객체만 사용하세요.
|
||||
- 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요.
|
||||
- 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요.
|
||||
- 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요.
|
||||
|
||||
사용자 질문: %s
|
||||
""".formatted(principal.employeeCode(), prompt);
|
||||
}
|
||||
|
||||
private String generate(
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String prompt
|
||||
@@ -110,20 +132,25 @@ public class SelectAiService {
|
||||
private QueryExecution executeReadOnly(
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String generatedSql,
|
||||
String bearerToken
|
||||
String bearerToken,
|
||||
HmmMcpPrincipal principal
|
||||
) {
|
||||
ArrayNode items = objectMapper.createArrayNode();
|
||||
boolean truncated = false;
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) {
|
||||
selectAi.runtimeDbUrl(),
|
||||
selectAi.runtimeDbUsername(),
|
||||
selectAi.runtimeDbPassword())) {
|
||||
verifyNonExemptRuntime(connection);
|
||||
boolean contextSet = false;
|
||||
try {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
|
||||
statement.setString(1, bearerToken);
|
||||
"BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) {
|
||||
statement.setString(1, "Bearer " + bearerToken);
|
||||
statement.execute();
|
||||
contextSet = true;
|
||||
}
|
||||
verifyVpdContext(connection, principal);
|
||||
connection.setAutoCommit(false);
|
||||
connection.setReadOnly(true);
|
||||
try (Statement transaction = connection.createStatement()) {
|
||||
@@ -148,7 +175,7 @@ public class SelectAiService {
|
||||
if (column == null || column.isBlank()) {
|
||||
column = metadata.getColumnName(columnIndex);
|
||||
}
|
||||
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||
putResultValue(row, column, resultSet, columnIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +186,7 @@ public class SelectAiService {
|
||||
} finally {
|
||||
if (contextSet) {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) {
|
||||
"BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) {
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
@@ -171,7 +198,53 @@ public class SelectAiService {
|
||||
return new QueryExecution(items, truncated);
|
||||
}
|
||||
|
||||
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||
private void verifyNonExemptRuntime(Connection connection) throws Exception {
|
||||
String runtimeUser;
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery("SELECT USER FROM dual")) {
|
||||
if (!resultSet.next()) {
|
||||
throw new AppException("VPD 런타임 DB 사용자를 확인할 수 없습니다.");
|
||||
}
|
||||
runtimeUser = resultSet.getString(1);
|
||||
}
|
||||
if (runtimeUser == null || "ADMIN".equals(runtimeUser.toUpperCase(Locale.ROOT))) {
|
||||
throw new AppException("VPD 런타임 연결은 ADMIN을 사용할 수 없습니다.");
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT COUNT(*) FROM SESSION_PRIVS WHERE PRIVILEGE = ?")) {
|
||||
statement.setString(1, "EXEMPT ACCESS POLICY");
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next() || resultSet.getInt(1) != 0) {
|
||||
throw new AppException(
|
||||
"VPD 런타임 계정에 EXEMPT ACCESS POLICY가 있어 실행을 차단했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyVpdContext(
|
||||
Connection connection,
|
||||
HmmMcpPrincipal principal
|
||||
) throws Exception {
|
||||
String sql = "SELECT SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE') FROM dual";
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery(sql)) {
|
||||
String employeeCode =
|
||||
resultSet.next() ? resultSet.getString(1) : null;
|
||||
if (employeeCode == null
|
||||
|| !employeeCode.equalsIgnoreCase(principal.employeeCode())) {
|
||||
throw new AppException("Bearer Token 사용자와 VPD 세션 컨텍스트가 일치하지 않습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void putResultValue(
|
||||
ObjectNode row,
|
||||
String column,
|
||||
ResultSet resultSet,
|
||||
int columnIndex
|
||||
) throws Exception {
|
||||
Object value = resultSet.getObject(columnIndex);
|
||||
if (value == null) {
|
||||
row.putNull(column);
|
||||
} else if (value instanceof BigDecimal number) {
|
||||
@@ -191,7 +264,9 @@ public class SelectAiService {
|
||||
} else if (value instanceof Boolean bool) {
|
||||
row.put(column, bool);
|
||||
} else {
|
||||
row.put(column, String.valueOf(value));
|
||||
// Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as
|
||||
// oracle.sql.TIMESTAMPTZ@<identity>, which is not usable MCP evidence.
|
||||
row.put(column, resultSet.getString(columnIndex));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,10 +69,15 @@ 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:
|
||||
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
|
||||
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
|
||||
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
|
||||
# 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:}
|
||||
catalog:
|
||||
owner: ${BACKOFFICE_CATALOG_OWNER:}
|
||||
objects: ${BACKOFFICE_CATALOG_OBJECTS:}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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;
|
||||
@@ -106,6 +108,50 @@ class McpSseServiceTest {
|
||||
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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||
McpSseService rejectingService = new McpSseService(
|
||||
|
||||
Reference in New Issue
Block a user