[Developer] #567 expose VPD effective SQL trace
This commit is contained in:
@@ -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 -> "권한에 따라 데이터를 볼 수 있습니다.";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <= ...</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user