468 lines
17 KiB
Java
468 lines
17 KiB
Java
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<String, Object>> 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<String> 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<Map<String, Object>> rows = new ArrayList<>();
|
|
Set<String> columns = new LinkedHashSet<>();
|
|
for (JsonNode rowNode : rowsNode) {
|
|
Map<String, Object> 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<String> 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<String>) 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<String> findMaskedColumns(long objectId, List<Map<String, Object>> rows) {
|
|
if (rows.isEmpty()) {
|
|
return List.of();
|
|
}
|
|
List<ProtectedColumn> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
|
|
.filter(ProtectedColumn::sensitive)
|
|
.toList();
|
|
if (sensitiveColumns.isEmpty()) {
|
|
return List.of();
|
|
}
|
|
|
|
List<String> masked = new ArrayList<>();
|
|
for (ProtectedColumn column : sensitiveColumns) {
|
|
boolean present = false;
|
|
boolean allNull = true;
|
|
for (Map<String, Object> row : rows) {
|
|
for (Map.Entry<String, Object> 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<String> 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);
|
|
}
|
|
}
|
|
}
|