[Developer] #567 expose VPD effective SQL trace

This commit is contained in:
devmrko
2026-06-30 11:40:02 +09:00
parent 5c40a9aa33
commit 3a07788e5c
17 changed files with 675 additions and 5 deletions

View File

@@ -14,9 +14,41 @@ public record ProbeResult(
String requestHeaders,
String requestPayload,
String responseHeaders,
String responseBody
String responseBody,
String vpdPredicate,
String effectiveSql
) {
public ProbeResult(
ProbeStatus status,
List<String> columns,
List<Map<String, Object>> rows,
int rowCount,
List<String> maskedColumns,
String errorCode,
String errorMessage,
String requestHeaders,
String requestPayload,
String responseHeaders,
String responseBody
) {
this(
status,
columns,
rows,
rowCount,
maskedColumns,
errorCode,
errorMessage,
requestHeaders,
requestPayload,
responseHeaders,
responseBody,
null,
null
);
}
public static ProbeResult blocked(ProbeStatus status, String errorCode, String errorMessage) {
return blocked(status, errorCode, errorMessage, null, null, null, null);
}
@@ -49,6 +81,28 @@ public record ProbeResult(
return status == ProbeStatus.SUCCESS || status == ProbeStatus.VPD_DENY_EMPTY_RESULT;
}
public boolean hasSqlTrace() {
return effectiveSql != null && !effectiveSql.isBlank();
}
public ProbeResult withSqlTrace(String predicate, String sql) {
return new ProbeResult(
status,
columns,
rows,
rowCount,
maskedColumns,
errorCode,
errorMessage,
requestHeaders,
requestPayload,
responseHeaders,
responseBody,
predicate,
sql
);
}
public String title() {
return switch (status) {
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";

View File

@@ -249,12 +249,30 @@ public class OrdsMetadataService {
.map(column -> "o." + column)
.reduce((left, right) -> left + ",\n " + right)
.orElseThrow();
String traceSelectColumns = columns.stream()
.map(column -> "o." + column)
.reduce((left, right) -> left + ", " + right)
.orElseThrow();
return """
DECLARE
v_rows SYS_REFCURSOR;
v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767);
BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header);
-- This is the same predicate function invoked by DBMS_RLS for the
-- SELECT below. It is returned only as diagnostic metadata.
BEGIN
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('%s', '%s');
v_effective_sql := 'SELECT %s FROM %s.%s o WHERE (' || v_vpd_predicate
|| ') AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)';
EXCEPTION
WHEN OTHERS THEN
v_vpd_predicate := NULL;
v_effective_sql := NULL;
END;
OPEN v_rows FOR
SELECT %s
FROM %s.%s o
@@ -266,6 +284,10 @@ public class OrdsMetadataService {
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
IF v_effective_sql IS NOT NULL THEN
APEX_JSON.WRITE('vpd_predicate', v_vpd_predicate);
APEX_JSON.WRITE('effective_sql', v_effective_sql);
END IF;
APEX_JSON.WRITE('items', v_rows);
APEX_JSON.CLOSE_OBJECT;
@@ -279,9 +301,17 @@ public class OrdsMetadataService {
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('error', SQLERRM);
APEX_JSON.CLOSE_OBJECT;
APEX_JSON.CLOSE_OBJECT;
END;
""".formatted(selectColumns, object.owner(), object.objectName());
""".formatted(
object.owner(),
object.objectName(),
traceSelectColumns,
object.owner(),
object.objectName(),
selectColumns,
object.owner(),
object.objectName());
}
private void rejectGenericVectorHandler(ProtectedObject object) {

View File

@@ -11,6 +11,9 @@ 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;
@@ -20,11 +23,15 @@ 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;
@@ -36,6 +43,7 @@ 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;
@@ -44,6 +52,7 @@ public class OrdsProbeService {
private final RestTemplate ordsRestTemplate;
private final ObjectMapper objectMapper;
private final SettingService settingService;
private final JdbcTemplate jdbcTemplate;
private final Clock clock;
public OrdsProbeService(
@@ -54,6 +63,7 @@ public class OrdsProbeService {
RestTemplate ordsRestTemplate,
ObjectMapper objectMapper,
SettingService settingService,
JdbcTemplate jdbcTemplate,
Clock clock
) {
this.tokenService = tokenService;
@@ -63,6 +73,7 @@ public class OrdsProbeService {
this.ordsRestTemplate = ordsRestTemplate;
this.objectMapper = objectMapper;
this.settingService = settingService;
this.jdbcTemplate = jdbcTemplate;
this.clock = clock;
}
@@ -131,6 +142,9 @@ public class OrdsProbeService {
prettyHeaders(response.getHeaders()),
prettyJson(response.getBody())
);
if (!result.hasSqlTrace() && !isVectorSearchObject(object)) {
result = addLocalSqlTrace(result, command.bearerToken(), object);
}
return auditAndReturn(command, result);
} catch (HttpStatusCodeException e) {
ProbeStatus status = errorClassifier.classify(e.getStatusCode(), e.getResponseBodyAsString());
@@ -205,10 +219,93 @@ public class OrdsProbeService {
requestHeaders,
requestPayload,
responseHeaders,
responseBody
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 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();