refs #740: enforce HMM MCP VPD runtime boundary

This commit is contained in:
devmrko
2026-07-31 13:57:04 +09:00
parent af6add5a44
commit 2e44ed0b97
8 changed files with 468 additions and 26 deletions

View File

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