package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.math.BigDecimal; import java.math.BigInteger; import java.sql.CallableStatement; import java.sql.Connection; import java.sql.DriverManager; 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; /** Generates and executes bounded read-only SQL through a configured Select AI profile. */ @Service public class SelectAiService { private static final int MAX_PROMPT_LENGTH = 4_000; private static final int MAX_RESULT_ROWS = 100; private static final int QUERY_TIMEOUT_SECONDS = 30; private static final Pattern UNSAFE_SQL = Pattern.compile( "(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|" + "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|" + "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b" ); private final BackofficeProperties properties; private final HmmMcpBearerAuthenticator bearerAuthenticator; private final ObjectMapper objectMapper; public SelectAiService( BackofficeProperties properties, HmmMcpBearerAuthenticator bearerAuthenticator, ObjectMapper objectMapper ) { this.properties = properties; this.bearerAuthenticator = bearerAuthenticator; this.objectMapper = objectMapper; } public JsonNode generateAndExecute(String bearerToken, String prompt) { HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || !selectAi.configured()) { throw new AppException("Select AI 연결 설정이 필요합니다. " + "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, vpdAwarePrompt(normalizedPrompt, principal)); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly( selectAi, normalizedSql, bearerToken.trim(), principal); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); response.put("generatedSql", normalizedSql); 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 + "건만 반환했습니다." : "생성 SQL을 읽기 전용으로 실행한 결과입니다."); return response; } 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 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 ) { 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("Select AI SHOWSQL 생성 실패: " + safeMessage(exception)); } } private QueryExecution executeReadOnly( BackofficeProperties.SelectAi selectAi, String generatedSql, String bearerToken, HmmMcpPrincipal principal ) { ArrayNode items = objectMapper.createArrayNode(); boolean truncated = false; try (Connection connection = DriverManager.getConnection( selectAi.runtimeDbUrl(), selectAi.runtimeDbUsername(), selectAi.runtimeDbPassword())) { verifyNonExemptRuntime(connection); boolean contextSet = false; try { try (CallableStatement statement = connection.prepareCall( "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()) { transaction.execute("SET TRANSACTION READ ONLY"); } try (PreparedStatement statement = connection.prepareStatement(generatedSql)) { statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); statement.setFetchSize(MAX_RESULT_ROWS + 1); statement.setMaxRows(MAX_RESULT_ROWS + 1); try (ResultSet resultSet = statement.executeQuery()) { ResultSetMetaData metadata = resultSet.getMetaData(); while (resultSet.next()) { if (items.size() >= MAX_RESULT_ROWS) { truncated = true; break; } ObjectNode row = items.addObject(); for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) { String column = metadata.getColumnLabel(columnIndex); if (column == null || column.isBlank()) { column = metadata.getColumnName(columnIndex); } putResultValue(row, column, resultSet, columnIndex); } } } } } finally { try { connection.rollback(); } finally { if (contextSet) { try (CallableStatement statement = connection.prepareCall( "BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) { statement.execute(); } } } } } catch (Exception exception) { throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception)); } return new QueryExecution(items, truncated); } 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) { row.put(column, number); } else if (value instanceof BigInteger number) { row.put(column, number); } else if (value instanceof Integer number) { row.put(column, number); } else if (value instanceof Long number) { row.put(column, number); } else if (value instanceof Short number) { row.put(column, number); } else if (value instanceof Float number) { row.put(column, number); } else if (value instanceof Double number) { row.put(column, number); } else if (value instanceof Boolean bool) { row.put(column, bool); } else { // Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as // oracle.sql.TIMESTAMPTZ@, which is not usable MCP evidence. row.put(column, resultSet.getString(columnIndex)); } } 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 문장이 포함되어 있어 실행하지 않습니다."); } if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/") || UNSAFE_SQL.matcher(normalized).find()) { throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다."); } return normalized; } private String safeMessage(Exception exception) { String message = exception.getMessage(); return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; } private record QueryExecution(ArrayNode items, boolean truncated) { } }