[Developer] #567 show recent SQL cursor candidates

This commit is contained in:
devmrko
2026-07-01 15:11:25 +09:00
parent d301e4dad1
commit b39968d43f
8 changed files with 127 additions and 15 deletions

View File

@@ -18,7 +18,8 @@ public record ProbeResult(
String vpdPredicate,
String effectiveSql,
SqlExecutionEvidence executionEvidence,
String executionEvidenceMessage
String executionEvidenceMessage,
List<SqlExecutionCandidate> executionCandidates
) {
public ProbeResult(
@@ -49,7 +50,8 @@ public record ProbeResult(
null,
null,
null,
null
null,
List.of()
);
}
@@ -83,7 +85,8 @@ public record ProbeResult(
vpdPredicate,
effectiveSql,
null,
null
null,
List.of()
);
}
@@ -139,7 +142,8 @@ public record ProbeResult(
predicate,
sql,
executionEvidence,
executionEvidenceMessage
executionEvidenceMessage,
executionCandidates
);
}
@@ -151,6 +155,14 @@ public record ProbeResult(
public ProbeResult withExecutionEvidence(
SqlExecutionEvidence evidence,
String unavailableMessage
) {
return withExecutionEvidence(evidence, unavailableMessage, List.of());
}
public ProbeResult withExecutionEvidence(
SqlExecutionEvidence evidence,
String unavailableMessage,
List<SqlExecutionCandidate> candidates
) {
return new ProbeResult(
status,
@@ -167,10 +179,15 @@ public record ProbeResult(
vpdPredicate,
effectiveSql,
evidence,
unavailableMessage
unavailableMessage,
candidates == null ? List.of() : List.copyOf(candidates)
);
}
public boolean hasExecutionCandidates() {
return executionCandidates != null && !executionCandidates.isEmpty();
}
public String title() {
return switch (status) {
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";

View File

@@ -0,0 +1,16 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
/**
* One of the most recently active database cursors shown verbatim during a
* probe. The match flag is calculated in Java, not by SQL text filtering in
* the database.
*/
public record SqlExecutionCandidate(
String sqlId,
int childNumber,
String parsingSchema,
String originalSql,
String lastActiveAt,
boolean matchesProtectedObject
) {
}

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.SqlExecutionCandidate;
import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionEvidence;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
@@ -49,6 +50,7 @@ public class OrdsProbeService {
private record ExecutionEvidenceLookup(
SqlExecutionEvidence evidence,
List<SqlExecutionCandidate> candidates,
boolean sqlCatalogAccessUnavailable
) {
}
@@ -56,6 +58,7 @@ public class OrdsProbeService {
private record RecentSqlCursor(
String sqlId,
int childNumber,
String parsingSchema,
String sqlFulltext,
String lastActiveAt,
long executions,
@@ -270,30 +273,33 @@ public class OrdsProbeService {
if (lookup.sqlCatalogAccessUnavailable()) {
return result.withExecutionEvidence(null,
"백오피스 DB 연결이 실제 DB cursor SQL을 읽지 못했습니다. 현재 권한 검증 결과는 정상입니다. "
+ "V$SQL과 DBMS_XPLAN은 일반 데이터 접근과 별도인 SYS 진단 권한입니다.");
+ "V$SQL과 DBMS_XPLAN은 일반 데이터 접근과 별도인 SYS 진단 권한입니다.",
lookup.candidates());
}
return result.withExecutionEvidence(null,
"최근 15분의 최신 실행 cursor 10건에서 이 보호 대상을 찾지 못했습니다. "
+ "ORDS 응답 지연 또는 shared pool 교체로 증적이 남지 않았을 수 있습니다.");
+ "아래 원문 SQL을 직접 확인하세요.",
lookup.candidates());
}
String message = evidence.hasPredicatePlan() ? null
: "SQL_ID는 찾았지만 DBMS_XPLAN Predicate Information을 읽지 못했습니다. "
+ "백오피스 DB 계정에 V$SQL/DBMS_XPLAN 조회 권한이 필요합니다.";
return result.withExecutionEvidence(evidence, message);
return result.withExecutionEvidence(evidence, message, lookup.candidates());
} 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 조회 권한이 필요합니다.");
"DB 실행 증적을 읽지 못했습니다. 백오피스 DB 계정에 V$SQL과 DBMS_XPLAN 조회 권한이 필요합니다.",
List.of());
}
}
private ExecutionEvidenceLookup findRecentExecutionEvidence(ProtectedObject object) {
try {
return new ExecutionEvidenceLookup(findRecentExecutionEvidence(jdbcTemplate, object), false);
return findRecentExecutionEvidence(jdbcTemplate, object);
} catch (RuntimeException exception) {
log.debug("Backoffice cursor evidence is unavailable: {}", exception.getMessage());
return new ExecutionEvidenceLookup(null, isSqlCatalogAccessUnavailable(exception));
return new ExecutionEvidenceLookup(null, List.of(), isSqlCatalogAccessUnavailable(exception));
}
}
@@ -308,13 +314,14 @@ public class OrdsProbeService {
|| normalized.contains("V$SQL");
}
private SqlExecutionEvidence findRecentExecutionEvidence(
private ExecutionEvidenceLookup findRecentExecutionEvidence(
JdbcTemplate evidenceJdbcTemplate,
ProtectedObject object
) {
List<RecentSqlCursor> candidates = evidenceJdbcTemplate.query("""
SELECT sql_id,
child_number,
parsing_schema_name,
sql_fulltext,
last_active_time,
executions,
@@ -324,6 +331,7 @@ public class OrdsProbeService {
FROM (
SELECT sql_id,
child_number,
parsing_schema_name,
sql_fulltext,
last_active_time,
executions,
@@ -338,6 +346,7 @@ public class OrdsProbeService {
""", (resultSet, rowNum) -> new RecentSqlCursor(
resultSet.getString("sql_id"),
resultSet.getInt("child_number"),
resultSet.getString("parsing_schema_name"),
resultSet.getString("sql_fulltext"),
resultSet.getTimestamp("last_active_time") == null
? null : resultSet.getTimestamp("last_active_time").toLocalDateTime().toString(),
@@ -347,7 +356,18 @@ public class OrdsProbeService {
resultSet.getLong("buffer_gets")
), EXECUTION_EVIDENCE_LOOKBACK_MINUTES);
return candidates.stream()
List<SqlExecutionCandidate> candidateViews = candidates.stream()
.map(candidate -> new SqlExecutionCandidate(
candidate.sqlId(),
candidate.childNumber(),
candidate.parsingSchema(),
candidate.sqlFulltext(),
candidate.lastActiveAt(),
referencesProtectedObject(candidate.sqlFulltext(), object)
))
.toList();
SqlExecutionEvidence evidence = candidates.stream()
.filter(candidate -> referencesProtectedObject(candidate.sqlFulltext(), object))
.findFirst()
.map(candidate -> new SqlExecutionEvidence(
@@ -362,6 +382,7 @@ public class OrdsProbeService {
findPredicatePlan(evidenceJdbcTemplate, candidate.sqlId(), candidate.childNumber())
))
.orElse(null);
return new ExecutionEvidenceLookup(evidence, candidateViews, false);
}
static boolean referencesProtectedObject(String sqlFulltext, ProtectedObject object) {

View File

@@ -142,6 +142,29 @@
th:text="${result.executionEvidenceMessage()}"></div>
</section>
<section class="result-section" th:if="${result.hasExecutionCandidates()}">
<div class="section-heading compact-heading">
<div>
<h3>최근 DB cursor 10건</h3>
<p class="section-subtitle">백오피스 DB 연결이 읽은 최신 cursor입니다. 보호 대상과 일치한 SQL은 초록 배지로 표시합니다.</p>
</div>
<span class="badge text-bg-light" th:text="${result.executionCandidates().size() + '건'}">10건</span>
</div>
<article class="probe-exchange mt-3" th:each="candidate : ${result.executionCandidates()}">
<div class="section-heading compact-heading">
<div>
<h3 th:text="${'SQL_ID ' + candidate.sqlId()}">SQL_ID</h3>
<p class="form-hint mb-0"
th:text="${'최근 실행: ' + (candidate.lastActiveAt() ?: '-') + ' · Parsing schema: ' + (candidate.parsingSchema() ?: '-') + ' · Child cursor: ' + candidate.childNumber()}">최근 실행</p>
</div>
<span class="badge"
th:classappend="${candidate.matchesProtectedObject()} ? ' text-bg-success' : ' text-bg-light'"
th:text="${candidate.matchesProtectedObject()} ? '보호 대상 일치' : '다른 SQL'">다른 SQL</span>
</div>
<pre class="mt-2 mb-0" th:text="${candidate.originalSql()} ?: '-'">SELECT ...</pre>
</article>
</section>
<section class="result-section sql-trace-section" th:if="${result.hasSqlTrace()}">
<div class="section-heading compact-heading">
<div>

View File

@@ -156,4 +156,28 @@ class ProbeResultTest {
assertThat(result.executionEvidence().hasPredicatePlan()).isTrue();
assertThat(result.effectiveSql()).contains("WHERE");
}
@Test
void keepsRecentCursorCandidatesWhenNoObjectMatchWasFound() {
ProbeResult result = new ProbeResult(
ProbeStatus.SUCCESS,
List.of(),
List.of(),
0,
List.of(),
null,
null,
null,
null,
null,
null
).withExecutionEvidence(null, "최근 cursor를 찾지 못했습니다.", List.of(
new SqlExecutionCandidate(
"candidate1", 0, "CB_ORDS", "SELECT 1 FROM dual", "2026-07-01T15:00", false)
));
assertThat(result.hasExecutionEvidence()).isFalse();
assertThat(result.hasExecutionCandidates()).isTrue();
assertThat(result.executionCandidates()).hasSize(1);
}
}

View File

@@ -110,6 +110,7 @@ class GuidedFlowTemplateTest {
assertThat(result)
.contains("적용된 사용자와 권한")
.contains("DB 실행 증적")
.contains("최근 DB cursor 10건")
.contains("실행 요청 SQL")
.contains("실제 DB cursor SQL")
.contains("set_vpd_context 사용자 컨텍스트")

View File

@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionCandidate;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
@@ -38,7 +39,10 @@ class ProbeResultTemplateRenderTest {
"{}",
"{}"
).withExecutionEvidence(null,
"백오피스 DB 연결이 실제 DB cursor SQL을 읽지 못했습니다. 현재 권한 검증 결과는 정상입니다.");
"최근 cursor를 찾지 못했습니다.",
List.of(new SqlExecutionCandidate(
"4f2jz7n2k0s3p", 0, "CB_ORDS",
"SELECT * FROM ADMIN.CB_V_SEARCH_DOCUMENTS", "2026-07-01T15:00", true)));
var context = new Context(Locale.KOREAN);
context.setVariable("result", result);
context.setVariable("vectorSearch", false);
@@ -47,6 +51,10 @@ class ProbeResultTemplateRenderTest {
String rendered = engine.process("fragments/probe-result", context);
assertThat(rendered).contains("백오피스 DB 연결이 실제 DB cursor SQL을 읽지 못했습니다.");
assertThat(rendered)
.contains("최근 cursor를 찾지 못했습니다.")
.contains("최근 DB cursor 10건")
.contains("4f2jz7n2k0s3p")
.contains("보호 대상 일치");
}
}