package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.JdbcTemplate; 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.slf4j.Logger; import org.slf4j.LoggerFactory; 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; @Service public class OrdsProbeService { private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; private static final Logger log = LoggerFactory.getLogger(OrdsProbeService.class); private final BearerTokenService tokenService; private final ProtectedObjectService protectedObjectService; private final AuditService auditService; private final ProbeErrorClassifier errorClassifier; private final RestTemplate ordsRestTemplate; private final ObjectMapper objectMapper; private final SettingService settingService; private final JdbcTemplate jdbcTemplate; private final Clock clock; public OrdsProbeService( BearerTokenService tokenService, ProtectedObjectService protectedObjectService, AuditService auditService, ProbeErrorClassifier errorClassifier, RestTemplate ordsRestTemplate, ObjectMapper objectMapper, SettingService settingService, JdbcTemplate jdbcTemplate, Clock clock ) { this.tokenService = tokenService; this.protectedObjectService = protectedObjectService; this.auditService = auditService; this.errorClassifier = errorClassifier; this.ordsRestTemplate = ordsRestTemplate; this.objectMapper = objectMapper; this.settingService = settingService; this.jdbcTemplate = jdbcTemplate; this.clock = clock; } public ProbeResult runProbe(ProbeCommand command) { String ordsBaseUrl = settingService.ordsBaseUrl(); if (ordsBaseUrl == null || ordsBaseUrl.isBlank()) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.ORDS_NOT_CONFIGURED, ProbeStatus.ORDS_NOT_CONFIGURED.name(), "ORDS base URL이 설정되지 않았습니다. 실제 ORDS 도메인을 BACKOFFICE_ORDS_BASE_URL에 설정한 뒤 백오피스를 재시작하세요." )); } BearerTokenRecord token; if (command.tokenKeyId() == null) { token = tokenService.findByPlainToken(command.bearerToken()); if (token == null) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "현재 DB에 등록된 토큰이 아닙니다. 토큰 화면에서 이 환경의 새 토큰을 발급하세요." )); } } else { token = tokenService.findById(command.tokenKeyId()); if (token == null) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.INVALID_TOKEN, "INVALID_TOKEN", "선택한 등록 토큰을 찾을 수 없습니다.")); } if (!tokenService.matches(token, command.bearerToken())) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.INVALID_TOKEN, "INVALID_TOKEN", "입력한 Bearer Token 원문이 선택한 등록 토큰과 일치하지 않습니다.")); } } if (!token.active(LocalDateTime.now(clock.withZone(ZoneId.systemDefault())))) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다.")); } ProtectedObject object; try { object = protectedObjectService.assertEnabled(command.objectId()); } catch (AppException e) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.OBJECT_DISABLED, "OBJECT_DISABLED", e.getMessage())); } String requestHeaders = null; String requestPayload = null; try { URI uri = buildUri(ordsBaseUrl, object.ordsPath(), command.limit()); HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(command.bearerToken()); headers.setContentType(MediaType.APPLICATION_JSON); String requestBody = command.requestBody() == null || command.requestBody().isBlank() ? "{}" : command.requestBody().trim(); requestHeaders = prettyHeaders(maskedRequestHeaders(headers)); requestPayload = prettyJson(requestBody); ResponseEntity response = ordsRestTemplate.exchange( uri, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class); ProbeResult result = parseSuccess( response.getBody(), object.objectId(), requestHeaders, requestPayload, prettyHeaders(response.getHeaders()), prettyJson(response.getBody()) ); if (isVectorSearchObject(object)) { result = addVectorSqlTrace(result, command.bearerToken(), object); } else if (!result.hasSqlTrace()) { result = addLocalSqlTrace(result, command.bearerToken(), object); } return auditAndReturn(command, result); } catch (HttpStatusCodeException e) { ProbeStatus status = errorClassifier.classify(e.getStatusCode(), e.getResponseBodyAsString()); return auditAndReturn(command, ProbeResult.blocked( status, status.name(), httpErrorMessage(status, e.getResponseBodyAsString()), requestHeaders, requestPayload, prettyHeaders(e.getResponseHeaders()), prettyJson(e.getResponseBodyAsString()) )); } catch (ResourceAccessException e) { ProbeStatus status = classifyResourceAccess(e); return auditAndReturn(command, ProbeResult.blocked( status, status.name(), resourceAccessMessage(status, e), requestHeaders, requestPayload, "{}", "" )); } catch (Exception e) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.INVALID_ORDS_RESPONSE, "INVALID_ORDS_RESPONSE", e.getMessage())); } } private URI buildUri(String baseUrl, String ordsPath, int limit) { String path = ordsPath.startsWith("/") ? ordsPath.substring(1) : ordsPath; return UriComponentsBuilder.fromUriString(baseUrl) .path("/") .path(path) .queryParam("limit", limit) .build() .toUri(); } private ProbeResult parseSuccess( String body, long objectId, String requestHeaders, String requestPayload, String responseHeaders, String responseBody ) throws Exception { JsonNode root = objectMapper.readTree(body); JsonNode rowsNode = root.has("rows") ? root.get("rows") : root; rowsNode = root.has("items") ? root.get("items") : rowsNode; if (!rowsNode.isArray()) { throw new AppException("ORDS 응답에 rows 배열이 없습니다."); } List> rows = new ArrayList<>(); Set columns = new LinkedHashSet<>(); for (JsonNode rowNode : rowsNode) { Map row = objectMapper.convertValue(rowNode, MAP_TYPE); rows.add(row); columns.addAll(row.keySet()); } ProbeStatus status = rows.isEmpty() ? ProbeStatus.VPD_DENY_EMPTY_RESULT : ProbeStatus.SUCCESS; return new ProbeResult( status, List.copyOf(columns), rows, rows.size(), findMaskedColumns(objectId, rows), null, null, requestHeaders, requestPayload, responseHeaders, responseBody, traceValue(root, "vpd_predicate"), traceValue(root, "effective_sql") ); } private String traceValue(JsonNode root, String fieldName) { JsonNode value = root == null ? null : root.get(fieldName); if (value == null || value.isNull() || !value.isValueNode()) { return null; } String text = value.asText(); return text == null || text.isBlank() ? null : text; } private ProbeResult addLocalSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object) { String predicate = findVpdPredicate(bearerToken, object); if (predicate == null || predicate.isBlank()) { return result; } List columns; try { columns = protectedObjectService.findColumns(object.objectId()).stream() .map(column -> "o." + column.columnName()) .toList(); } catch (RuntimeException ignored) { return result; } if (columns.isEmpty()) { return result; } String effectiveSql = "SELECT " + String.join(", ", columns) + " FROM " + object.owner() + "." + object.objectName() + " o" + " WHERE (" + predicate + ")" + " AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)"; return result.withSqlTrace(predicate, effectiveSql); } private ProbeResult addVectorSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object) { String predicate = findVpdPredicate(bearerToken, object); if (predicate == null || predicate.isBlank()) { return result; } return result.withSqlTrace(predicate, vectorEffectiveSql(object, predicate)); } static String vectorEffectiveSql(ProtectedObject object, String predicate) { return "SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score" + " FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text," + " d.source_uri, d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score" + " FROM " + object.owner() + "." + object.objectName() + " d" + " WHERE d.embedding IS NOT NULL AND (" + predicate + ")" + " ORDER BY score) ranked_chunks" + " WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)"; } private String findVpdPredicate(String bearerToken, ProtectedObject object) { try { return jdbcTemplate.execute((ConnectionCallback) connection -> { try { executeContextSetter(connection, bearerToken); try (PreparedStatement statement = connection.prepareStatement( "SELECT admin.cb_agent_doc_vpd_filter(?, ?) FROM dual")) { statement.setString(1, object.owner()); statement.setString(2, object.objectName()); try (ResultSet result = statement.executeQuery()) { return result.next() ? result.getString(1) : null; } } } finally { clearContext(connection); } }); } catch (RuntimeException exception) { // The backoffice may use a different DB account or a database without // the optional trace privilege. The ORDS response remains authoritative // when the Handler itself returned trace fields. log.debug("VPD SQL trace unavailable for {}.{}: {}", object.owner(), object.objectName(), exception.getMessage()); return null; } } private void executeContextSetter(Connection connection, String bearerToken) throws java.sql.SQLException { try (PreparedStatement statement = connection.prepareStatement( "BEGIN admin.cb_agent_ctx_pkg.set_user_by_bearer(?); END;")) { statement.setString(1, bearerToken); statement.execute(); } } private void clearContext(Connection connection) { try (PreparedStatement statement = connection.prepareStatement( "BEGIN admin.cb_agent_ctx_pkg.clear_user; END;")) { statement.execute(); } catch (java.sql.SQLException exception) { // Do not replace a successful ORDS result with a diagnostic cleanup error. log.debug("VPD context cleanup failed: {}", exception.getMessage()); } } private boolean isVectorSearchObject(ProtectedObject object) { return "CB_VECTOR_SEARCH_DOCUMENTS".equalsIgnoreCase(object.objectName()); } private List findMaskedColumns(long objectId, List> rows) { if (rows.isEmpty()) { return List.of(); } List sensitiveColumns = protectedObjectService.findColumns(objectId).stream() .filter(ProtectedColumn::sensitive) .toList(); if (sensitiveColumns.isEmpty()) { return List.of(); } List masked = new ArrayList<>(); for (ProtectedColumn column : sensitiveColumns) { boolean present = false; boolean allNull = true; for (Map row : rows) { for (Map.Entry entry : row.entrySet()) { if (entry.getKey().equalsIgnoreCase(column.columnName())) { present = true; allNull = allNull && entry.getValue() == null; } } } if (present && allNull) { masked.add(column.columnName().toLowerCase(Locale.ROOT) + " [" + column.policyLabel() + "]"); } } return masked; } private ProbeResult auditAndReturn(ProbeCommand command, ProbeResult result) { auditService.record(new AuditEvent( "ORDS_PROBE", tokenKeyId(command), command.objectId(), result.status().name(), result.rowCount(), result.errorCode(), result.errorMessage() )); return result; } private Long tokenKeyId(ProbeCommand command) { if (command.tokenKeyId() != null) { return command.tokenKeyId(); } BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken()); return token == null ? null : token.keyId(); } private ProbeStatus classifyResourceAccess(ResourceAccessException exception) { if (errorClassifier.isTimeout(exception)) { return ProbeStatus.ORDS_TIMEOUT; } if (errorClassifier.isUnavailable(exception)) { return ProbeStatus.ORDS_UNAVAILABLE; } return ProbeStatus.UNKNOWN_ERROR; } private String resourceAccessMessage(ProbeStatus status, ResourceAccessException exception) { String detail = trimMessage(exception.getMessage()); if (status == ProbeStatus.ORDS_UNAVAILABLE) { return "ORDS 서버에 연결할 수 없습니다. BACKOFFICE_ORDS_BASE_URL의 실제 ORDS 도메인, ORDS 실행 상태, 네트워크 접근을 확인하세요. 상세: " + detail; } if (status == ProbeStatus.ORDS_TIMEOUT) { return "ORDS 응답 시간이 초과되었습니다. ORDS 상태와 BACKOFFICE_ORDS_TIMEOUT_SECONDS 설정을 확인하세요. 상세: " + detail; } return detail; } private String httpErrorMessage(ProbeStatus status, String body) { String detail = trimMessage(body); if (status == ProbeStatus.ORDS_PATH_NOT_FOUND) { return "ORDS 경로를 찾을 수 없습니다. 설정의 Base URL과 보호 객체 ORDS Path가 실제 ORDS schema mapping/module/template 경로와 일치하는지 확인하세요. 상세: " + detail; } return detail; } private String trimMessage(String body) { if (body == null) { return null; } return body.length() <= 500 ? body : body.substring(0, 500); } private HttpHeaders maskedRequestHeaders(HttpHeaders headers) { HttpHeaders masked = new HttpHeaders(); masked.putAll(headers); List authorization = headers.get(HttpHeaders.AUTHORIZATION); if (authorization != null && !authorization.isEmpty()) { masked.set(HttpHeaders.AUTHORIZATION, maskBearer(authorization.get(0))); } return masked; } private String maskBearer(String value) { if (value == null || value.isBlank()) { return ""; } if (!value.toLowerCase(Locale.ROOT).startsWith("bearer ")) { return "****"; } String token = value.substring("Bearer ".length()); String suffix = token.length() <= 6 ? "" : token.substring(token.length() - 6); return "Bearer ****" + suffix; } private String prettyHeaders(HttpHeaders headers) { if (headers == null || headers.isEmpty()) { return "{}"; } return prettyObject(headers); } private String prettyJson(String body) { if (body == null || body.isBlank()) { return ""; } try { return objectMapper.writerWithDefaultPrettyPrinter() .writeValueAsString(objectMapper.readTree(body)); } catch (Exception ignored) { return body; } } private String prettyObject(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); } catch (Exception ignored) { return String.valueOf(value); } } }