[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();

View File

@@ -70,6 +70,33 @@
</div>
</section>
<section class="result-section sql-trace-section" th:if="${result.hasSqlTrace()}">
<div class="section-heading compact-heading">
<div>
<h3>토큰 적용 후 SQL</h3>
<p class="section-subtitle">ORDS Handler가 반환한 VPD predicate 또는 같은 토큰 컨텍스트를 재현해 조회한 predicate를 기본 조회문에 합친 형태입니다.</p>
</div>
<span class="badge text-bg-light">DBMS_RLS predicate</span>
</div>
<div class="probe-exchange-grid mt-3">
<section class="probe-exchange" data-sql-trace-field="vpd_predicate">
<h3>VPD가 추가한 WHERE 조건</h3>
<pre th:text="${result.vpdPredicate()}">(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))</pre>
</section>
<section class="probe-exchange" data-sql-trace-field="effective_sql">
<h3>권한 적용 SQL</h3>
<pre th:text="${result.effectiveSql()}">SELECT ... WHERE (...) AND ROWNUM &lt;= ...</pre>
</section>
</div>
<p class="form-hint mt-2 mb-0">
Oracle 내부 optimizer의 실행계획이나 bind 값 치환 결과가 아니라, 이 토큰 컨텍스트에서 VPD 정책 함수가 실제로 반환한 행 조건을 표시합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
</p>
</section>
<div class="alert alert-light mb-0" th:if="${result.successLike() and !result.hasSqlTrace()}">
이 Handler는 SQL trace 정보를 반환하지 않았고 백오피스 DB에서도 기본 VPD predicate를 조회하지 못했습니다. <a href="/ords-handlers">ORDS 핸들러</a>에서 trace가 포함된 소스를 적용하고, <code>CB_AGENT_DOC_VPD_FILTER</code> 실행 권한을 확인하세요.
</div>
<section class="next-action-card">
<h3>다음에 할 일</h3>
<p th:text="${result.nextAction()}">다음 행동</p>

View File

@@ -9,10 +9,14 @@
<p class="context-summary">토큰을 DB 권한 컨텍스트로 바꾸는 Handler를 확인·수정합니다.</p>
<details class="explanation-details">
<summary>Handler 처리 흐름 설명 보기</summary>
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD가 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다.</p>
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD가 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다. 실행 결과의 “토큰 적용 후 SQL”은 권한 결과 확인 메뉴에서 확인할 수 있습니다.</p>
</details>
</div>
<div class="alert alert-info">
권한별로 VPD가 붙인 실제 행 조건을 확인하려면 <a href="/probe">권한 결과 확인</a>에서 이 Handler의 보호 객체를 실행하세요. SQL trace를 반환하도록 갱신된 Handler는 같은 ORDS 세션의 <code>CB_AGENT_DOC_VPD_FILTER</code> predicate를 함께 표시합니다.
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>

View File

@@ -97,4 +97,29 @@ class ProbeResultTest {
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
}
@Test
void exposesVpdSqlTraceWithoutChangingTheRows() {
ProbeResult result = new ProbeResult(
ProbeStatus.SUCCESS,
List.of("DEPT_CODE"),
List.of(Map.of("DEPT_CODE", "HR")),
1,
List.of(),
null,
null,
null,
null,
null,
null
).withSqlTrace(
"(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))",
"SELECT * FROM ADMIN.DOCUMENTS WHERE (DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))"
);
assertThat(result.hasSqlTrace()).isTrue();
assertThat(result.vpdPredicate()).contains("DEPT_CODE");
assertThat(result.effectiveSql()).contains("ADMIN.DOCUMENTS");
assertThat(result.rowCount()).isEqualTo(1);
}
}

View File

@@ -0,0 +1,54 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.OrdsMetadataMapper;
import java.util.List;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class OrdsMetadataServiceTest {
@Test
void generatedObjectHandlerIncludesTokenSpecificVpdSqlTrace() {
OrdsMetadataMapper mapper = mock(OrdsMetadataMapper.class);
ProtectedObjectService protectedObjectService = mock(ProtectedObjectService.class);
DataSource dataSource = mock(DataSource.class);
ProtectedObject object = new ProtectedObject(
7L,
"ADMIN",
"CB_V_SEARCH_DOCUMENTS",
"cb-ords/cb-object-query/admin/cb_v_search_documents",
"Y"
);
when(protectedObjectService.assertEnabled(7L)).thenReturn(object);
when(protectedObjectService.findColumns(7L)).thenReturn(List.of(
new ProtectedColumn(1L, 7L, "DOC_ID", "N", null, "PUBLIC", "NONE"),
new ProtectedColumn(2L, 7L, "DEPT_CODE", "N", null, "INTERNAL", "NONE")
));
OrdsMetadataService service = new OrdsMetadataService(
mapper,
new JdbcTemplate(dataSource),
protectedObjectService,
dataSource,
"",
"",
""
);
String source = service.objectQueryHandlerSource(7L);
assertThat(source)
.contains("cb_ords_handler_pkg.set_vpd_context(:auth_header)")
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
.contains("v_vpd_predicate")
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)")
.contains("SELECT o.DOC_ID, o.DEPT_CODE FROM ADMIN.CB_V_SEARCH_DOCUMENTS o");
}
}

View File

@@ -31,6 +31,8 @@ class GuidedFlowTemplateTest {
.doesNotContain("name=\"tokenKeyId\"");
assertThat(result)
.contains("적용된 사용자와 권한")
.contains("토큰 적용 후 SQL")
.contains("vpd_predicate")
.contains("다음에 할 일")
.contains("<details")
.contains("기술 상세");
@@ -138,6 +140,18 @@ class GuidedFlowTemplateTest {
.doesNotContain("JSON_QUERY(\n :body_text")
.doesNotContain("JSON_VALUE(\n :body_text");
assertThat(sql.split("v_body_text := :body_text", -1)).hasSize(2);
assertThat(sql).contains("vpd_predicate").contains("effective_sql");
}
@Test
void existingOrdsSetupCanReturnTheVpdPredicateTrace() throws IOException {
String setup = Files.readString(Path.of("sql/adb/22_agent_ords_security_ords_handler_setup.sql"));
String grant = Files.readString(Path.of("sql/adb/33_agent_ords_sql_trace_grant.sql"));
assertThat(setup)
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)");
assertThat(grant).contains("GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords");
}
private String template(String relativePath) throws IOException {