Files
vpd-permission-poc/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.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);
}
}
}