[Developer] #567 add VPD SQL execution evidence

This commit is contained in:
devmrko
2026-07-01 13:47:30 +09:00
parent 07c090f6b0
commit bf393bffa5
13 changed files with 398 additions and 11 deletions

View File

@@ -16,7 +16,9 @@ public record ProbeResult(
String responseHeaders,
String responseBody,
String vpdPredicate,
String effectiveSql
String effectiveSql,
SqlExecutionEvidence executionEvidence,
String executionEvidenceMessage
) {
public ProbeResult(
@@ -45,6 +47,42 @@ public record ProbeResult(
responseHeaders,
responseBody,
null,
null,
null,
null
);
}
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,
String vpdPredicate,
String effectiveSql
) {
this(
status,
columns,
rows,
rowCount,
maskedColumns,
errorCode,
errorMessage,
requestHeaders,
requestPayload,
responseHeaders,
responseBody,
vpdPredicate,
effectiveSql,
null,
null
);
}
@@ -99,7 +137,37 @@ public record ProbeResult(
responseHeaders,
responseBody,
predicate,
sql
sql,
executionEvidence,
executionEvidenceMessage
);
}
public boolean hasExecutionEvidence() {
return executionEvidence != null && executionEvidence.sqlId() != null
&& !executionEvidence.sqlId().isBlank();
}
public ProbeResult withExecutionEvidence(
SqlExecutionEvidence evidence,
String unavailableMessage
) {
return new ProbeResult(
status,
columns,
rows,
rowCount,
maskedColumns,
errorCode,
errorMessage,
requestHeaders,
requestPayload,
responseHeaders,
responseBody,
vpdPredicate,
effectiveSql,
evidence,
unavailableMessage
);
}

View File

@@ -0,0 +1,23 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
/**
* Database shared-pool evidence for the statement most recently executed for
* a protected object. The SQL text is the statement submitted by ORDS before
* VPD rewrite; Oracle exposes the applied predicate through the cursor plan.
*/
public record SqlExecutionEvidence(
String sqlId,
int childNumber,
String originalSql,
String lastActiveAt,
long executions,
long rowsProcessed,
long elapsedMillis,
long bufferGets,
String predicatePlan
) {
public boolean hasPredicatePlan() {
return predicatePlan != null && !predicatePlan.isBlank();
}
}

View File

@@ -94,6 +94,16 @@ public class OrdsMetadataService {
.toList();
}
/**
* Uses the optional CB_ORDS metadata connection when configured. SQL cursor
* evidence must be read from the parsing schema's database context, rather
* than from an unrelated backoffice connection.
*/
public JdbcTemplate executionEvidenceJdbcTemplate() {
return ordsMetadataJdbcTemplate;
}
public String objectQueryHandlerSource(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
rejectGenericVectorHandler(object);

View File

@@ -4,6 +4,7 @@ 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.probe.SqlExecutionEvidence;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
@@ -53,6 +54,7 @@ public class OrdsProbeService {
private final ObjectMapper objectMapper;
private final SettingService settingService;
private final JdbcTemplate jdbcTemplate;
private final OrdsMetadataService ordsMetadataService;
private final Clock clock;
public OrdsProbeService(
@@ -64,6 +66,7 @@ public class OrdsProbeService {
ObjectMapper objectMapper,
SettingService settingService,
JdbcTemplate jdbcTemplate,
OrdsMetadataService ordsMetadataService,
Clock clock
) {
this.tokenService = tokenService;
@@ -74,6 +77,7 @@ public class OrdsProbeService {
this.objectMapper = objectMapper;
this.settingService = settingService;
this.jdbcTemplate = jdbcTemplate;
this.ordsMetadataService = ordsMetadataService;
this.clock = clock;
}
@@ -142,6 +146,7 @@ public class OrdsProbeService {
prettyHeaders(response.getHeaders()),
prettyJson(response.getBody())
);
result = attachRecentExecutionEvidence(result, object);
if (isVectorSearchObject(object)) {
result = addVectorSqlTrace(result, command.bearerToken(), object);
} else if (!result.hasSqlTrace()) {
@@ -236,6 +241,114 @@ public class OrdsProbeService {
return text == null || text.isBlank() ? null : text;
}
/**
* V$SQL keeps the statement submitted by ORDS, not Oracle's internally
* rewritten VPD text. DBMS_XPLAN is therefore the authoritative place to
* show the predicate that the matching cursor applied.
*/
private ProbeResult attachRecentExecutionEvidence(ProbeResult result, ProtectedObject object) {
try {
SqlExecutionEvidence evidence = findRecentExecutionEvidence(object);
if (evidence == null) {
return result.withExecutionEvidence(null,
"최근 2분 내 이 보호 대상의 SQL_ID를 shared pool에서 찾지 못했습니다. "
+ "ORDS 실행 계정의 V$SQL 보존 시간과 대상 SQL을 확인하세요.");
}
String message = evidence.hasPredicatePlan() ? null
: "SQL_ID는 찾았지만 DBMS_XPLAN Predicate Information을 읽지 못했습니다. "
+ "백오피스 DB 계정에 V$SQL/DBMS_XPLAN 조회 권한이 필요합니다.";
return result.withExecutionEvidence(evidence, message);
} catch (RuntimeException exception) {
log.debug("Recent SQL execution evidence unavailable for {}.{}: {}",
object.owner(), object.objectName(), exception.getMessage());
return result.withExecutionEvidence(null,
"DB 실행 증적을 읽지 못했습니다. 백오피스 DB 계정에 V$SQL과 DBMS_XPLAN 조회 권한이 필요합니다.");
}
}
private SqlExecutionEvidence findRecentExecutionEvidence(ProtectedObject object) {
JdbcTemplate evidenceJdbcTemplate = ordsMetadataService.executionEvidenceJdbcTemplate();
try {
SqlExecutionEvidence evidence = findRecentExecutionEvidence(evidenceJdbcTemplate, object);
if (evidence != null || evidenceJdbcTemplate == jdbcTemplate) {
return evidence;
}
} catch (RuntimeException exception) {
log.debug("ORDS parsing-schema cursor evidence is unavailable: {}", exception.getMessage());
}
return findRecentExecutionEvidence(jdbcTemplate, object);
}
private SqlExecutionEvidence findRecentExecutionEvidence(
JdbcTemplate evidenceJdbcTemplate,
ProtectedObject object
) {
String marker = "%FROM%" + object.owner().toUpperCase(Locale.ROOT)
+ "." + object.objectName().toUpperCase(Locale.ROOT) + "%";
return evidenceJdbcTemplate.query("""
SELECT sql_id,
child_number,
sql_fulltext,
last_active_time,
executions,
rows_processed,
elapsed_time,
buffer_gets
FROM (
SELECT sql_id,
child_number,
sql_fulltext,
last_active_time,
executions,
rows_processed,
elapsed_time,
buffer_gets
FROM v$sql
WHERE UPPER(sql_text) LIKE ?
AND last_active_time >= SYSTIMESTAMP - INTERVAL '2' MINUTE
ORDER BY last_active_time DESC
)
WHERE ROWNUM = 1
""", resultSet -> {
if (!resultSet.next()) {
return null;
}
String sqlId = resultSet.getString("sql_id");
int childNumber = resultSet.getInt("child_number");
String predicatePlan = findPredicatePlan(evidenceJdbcTemplate, sqlId, childNumber);
return new SqlExecutionEvidence(
sqlId,
childNumber,
resultSet.getString("sql_fulltext"),
resultSet.getTimestamp("last_active_time") == null
? null : resultSet.getTimestamp("last_active_time").toLocalDateTime().toString(),
resultSet.getLong("executions"),
resultSet.getLong("rows_processed"),
Math.round(resultSet.getLong("elapsed_time") / 1000.0d),
resultSet.getLong("buffer_gets"),
predicatePlan
);
}, marker);
}
private String findPredicatePlan(
JdbcTemplate evidenceJdbcTemplate,
String sqlId,
int childNumber
) {
try {
List<String> lines = evidenceJdbcTemplate.query("""
SELECT plan_table_output
FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(?, ?, 'ALLSTATS LAST +PREDICATE +ALIAS'))
""", (resultSet, rowNum) -> resultSet.getString(1), sqlId, childNumber);
return lines.isEmpty() ? null : String.join(System.lineSeparator(), lines);
} catch (RuntimeException exception) {
log.debug("DBMS_XPLAN is unavailable for SQL_ID {} child {}: {}",
sqlId, childNumber, exception.getMessage());
return null;
}
}
private ProbeResult addLocalSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object) {
String predicate = findVpdPredicate(bearerToken, object);
if (predicate == null || predicate.isBlank()) {

View File

@@ -43,4 +43,5 @@ public class OrdsHandlerController {
}
return "redirect:/ords-handlers";
}
}

View File

@@ -108,12 +108,51 @@
</div>
</section>
<section class="result-section" th:if="${result.hasExecutionEvidence()}">
<div class="section-heading compact-heading">
<div>
<h3>DB 실행 증적</h3>
<p class="section-subtitle">최근 2분 내 같은 보호 대상에 실행된 ORDS SQL을 SQL_ID로 확인한 결과입니다.</p>
</div>
<span class="badge text-bg-success" th:text="${'SQL_ID ' + result.executionEvidence().sqlId()}">SQL_ID</span>
</div>
<div class="effective-preview">
<dl>
<div><dt>Child cursor</dt><dd th:text="${result.executionEvidence().childNumber()}">0</dd></div>
<div><dt>최근 실행</dt><dd th:text="${result.executionEvidence().lastActiveAt()} ?: '-'">2026-07-01T10:00</dd></div>
<div><dt>Cursor 실행 횟수</dt><dd th:text="${result.executionEvidence().executions()}">1</dd></div>
<div><dt>Cursor 처리 행</dt><dd th:text="${result.executionEvidence().rowsProcessed()}">10</dd></div>
<div><dt>누적 경과 시간</dt><dd th:text="${result.executionEvidence().elapsedMillis() + ' ms'}">10 ms</dd></div>
<div><dt>Buffer gets</dt><dd th:text="${result.executionEvidence().bufferGets()}">0</dd></div>
</dl>
</div>
<div class="probe-exchange-grid mt-3">
<section class="probe-exchange" data-sql-trace-field="executed_sql">
<h3>DB가 기록한 원문 SQL</h3>
<p class="form-hint">V$SQL의 원문입니다. Oracle VPD가 WHERE 조건을 내부적으로 주입하기 전 SQL 형태로 저장됩니다.</p>
<pre th:text="${result.executionEvidence().originalSql()}">SELECT ...</pre>
</section>
<section class="probe-exchange" data-sql-trace-field="execution_plan" th:if="${result.executionEvidence().hasPredicatePlan()}">
<h3>DBMS_XPLAN Predicate Information</h3>
<p class="form-hint">이 cursor의 실행계획에 기록된 Access/Filter predicate입니다. VPD 적용 근거는 여기서 확인합니다.</p>
<pre th:text="${result.executionEvidence().predicatePlan()}">Predicate Information</pre>
</section>
</div>
<div class="alert alert-light mt-3 mb-0" th:if="${result.executionEvidenceMessage() != null}"
th:text="${result.executionEvidenceMessage()}"></div>
</section>
<div class="alert alert-light mb-0" th:if="${result.successLike() and !result.hasExecutionEvidence() and result.executionEvidenceMessage() != null}"
th:text="${result.executionEvidenceMessage()}">
최근 SQL_ID를 찾지 못했습니다.
</div>
<section class="result-section sql-trace-section" th:if="${result.hasSqlTrace()}">
<div class="section-heading compact-heading">
<div>
<h3>토큰 적용 후 SQL</h3>
<h3>권한 조건 재현 SQL</h3>
<p class="section-subtitle"
th:text="${vectorSearch ? 'VECTOR_DISTANCE 유사도 검색과 현재 사용자의 역할 기반 VPD 권한 필터를 결합한 재현용 SQL입니다.' : 'ORDS Handler가 반환한 VPD predicate 또는 같은 토큰 컨텍스트를 재현해 조회한 predicate를 기본 조회문에 합친 형태입니다.'}">
th:text="${vectorSearch ? 'VECTOR_DISTANCE 유사도 검색과 현재 사용자의 역할 기반 VPD 권한 필터를 결합한 설명용 SQL입니다.' : 'ORDS Handler가 반환한 VPD predicate 또는 같은 토큰 컨텍스트를 기본 조회문에 결합한 설명용 SQL입니다.'}">
ORDS Handler가 반환한 VPD predicate 또는 같은 토큰 컨텍스트를 재현해 조회한 predicate를 기본 조회문에 합친 형태입니다.
</p>
</div>
@@ -159,7 +198,7 @@ ROWNUM &lt;= :row_limit</pre>
</section>
</div>
<p class="form-hint mt-2 mb-0">
Oracle 내부 optimizer의 실행계획이나 bind 값 치환 결과가 아니라, 이 토큰 컨텍스트에서 VPD 정책 함수가 실제로 반환한 행 조건을 표시합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
위 SQL은 권한 조건을 읽기 쉽게 재현한 표현입니다. 실제 실행 증적은 SQL_ID와 DBMS_XPLAN 영역에서 확인합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
</p>
</section>

View File

@@ -8,7 +8,7 @@
<h1>조회 연동</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD가 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다. 실행 결과의 “토큰 적용 후 SQL”은 접근 검증 메뉴에서 확인할 수 있습니다.</p>
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD가 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다. 실행 결과의 SQL_ID 증적과 권한 조건 재현은 접근 검증 메뉴에서 확인할 수 있습니다.</p>
</details>
</div>

View File

@@ -122,4 +122,38 @@ class ProbeResultTest {
assertThat(result.effectiveSql()).contains("ADMIN.DOCUMENTS");
assertThat(result.rowCount()).isEqualTo(1);
}
@Test
void keepsDatabaseExecutionEvidenceSeparateFromTheReconstructedSql() {
SqlExecutionEvidence evidence = new SqlExecutionEvidence(
"4f2jz7n2k0s3p",
1,
"SELECT d.doc_id FROM ADMIN.CB_V_SEARCH_DOCUMENTS d WHERE ROWNUM <= :1",
"2026-07-01T10:00:00",
3,
12,
35,
120,
"Predicate Information (identified by operation id):\n1 - filter(DEPT_CODE = SYS_CONTEXT(...))"
);
ProbeResult result = new ProbeResult(
ProbeStatus.SUCCESS,
List.of("DOC_ID"),
List.of(Map.of("DOC_ID", 1)),
1,
List.of(),
null,
null,
null,
null,
null,
null
).withSqlTrace("DEPT_CODE = SYS_CONTEXT(...)", "SELECT ... WHERE (...)")
.withExecutionEvidence(evidence, null);
assertThat(result.hasExecutionEvidence()).isTrue();
assertThat(result.executionEvidence().sqlId()).isEqualTo("4f2jz7n2k0s3p");
assertThat(result.executionEvidence().hasPredicatePlan()).isTrue();
assertThat(result.effectiveSql()).contains("WHERE");
}
}

View File

@@ -95,7 +95,8 @@ class GuidedFlowTemplateTest {
.doesNotContain("name=\"tokenKeyId\"");
assertThat(result)
.contains("적용된 사용자와 권한")
.contains("토큰 적용 후 SQL")
.contains("DB 실행 증적")
.contains("권한 조건 재현 SQL")
.contains("set_vpd_context 사용자 컨텍스트")
.contains("CB_AGENT_CTX.USER_ID")
.contains("벡터 유사도 검색 기준")
@@ -243,12 +244,16 @@ class GuidedFlowTemplateTest {
}
@Test
void probeResultExplainsVectorEffectiveSqlWhenTheHandlerReturnsTrace() throws IOException {
void probeResultSeparatesDbExecutionEvidenceFromReconstructedSql() throws IOException {
String result = template("fragments/probe-result.html");
String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));
assertThat(result)
.contains("토큰 적용 후 SQL")
.contains("DB 실행 증적")
.contains("DB가 기록한 원문 SQL")
.contains("DBMS_XPLAN Predicate Information")
.contains("SQL_ID")
.contains("권한 조건 재현 SQL")
.contains("권한 적용 SQL")
.contains("VPD가 추가한 WHERE 조건")
.contains("ALL: 추가 행 필터 없음")

View File

@@ -0,0 +1,51 @@
package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver;
class ProbeResultTemplateRenderTest {
@Test
void rendersRecentSqlEvidenceUnavailableMessageAsTextNotAsABoolean() {
var resolver = new FileTemplateResolver();
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
resolver.setSuffix(".html");
resolver.setTemplateMode("HTML");
resolver.setCacheable(false);
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
ProbeResult result = new ProbeResult(
ProbeStatus.SUCCESS,
List.of("DOC_ID"),
List.of(Map.of("DOC_ID", 1)),
1,
List.of(),
null,
null,
"{}",
"{}",
"{}",
"{}"
).withExecutionEvidence(null, "최근 SQL_ID를 찾지 못했습니다.");
var context = new Context(Locale.KOREAN);
context.setVariable("result", result);
context.setVariable("vectorSearch", false);
context.setVariable("tokenContext", null);
context.setVariable("selectedObject", null);
String rendered = engine.process("fragments/probe-result", context);
assertThat(rendered).contains("최근 SQL_ID를 찾지 못했습니다.");
}
}