Compare commits

..

2 Commits

Author SHA1 Message Date
devmrko
bf393bffa5 [Developer] #567 add VPD SQL execution evidence 2026-07-01 13:47:30 +09:00
devmrko
07c090f6b0 feat: show DDS SQL execution evidence 2026-07-01 13:39:38 +09:00
21 changed files with 578 additions and 19 deletions

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.ddsbackoffice.domain;
/** Read-only evidence collected immediately after a protected search executes. */
public record DdsSqlEvidence(
boolean available,
String requestId,
String submittedStatement,
String sqlId,
Integer childNumber,
String lastActiveTime,
Long executions,
String planPredicate,
String vpdPredicate,
String status
) {
public static DdsSqlEvidence unavailable(String requestId, String submittedStatement, String status) {
return new DdsSqlEvidence(false, requestId, submittedStatement, null, null, null, null, null, null, status);
}
}

View File

@@ -17,7 +17,8 @@ public record DdsVectorSearchResult(
String sessionUser, String sessionUser,
String endUser, String endUser,
Integer oracleCode, Integer oracleCode,
List<Map<String, Object>> rows List<Map<String, Object>> rows,
DdsSqlEvidence sqlEvidence
) { ) {
public DdsVectorSearchResult { public DdsVectorSearchResult {

View File

@@ -0,0 +1,24 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsSqlEvidence;
import java.util.ArrayDeque;
import java.util.List;
import org.springframework.stereotype.Service;
/** Keeps a small, redacted in-memory audit trail for the console session. */
@Service
public class DdsSqlEvidenceHistory {
private static final int MAX_ENTRIES = 20;
private final ArrayDeque<DdsSqlEvidence> entries = new ArrayDeque<>();
public synchronized void record(DdsSqlEvidence evidence) {
if (evidence == null) return;
entries.removeIf(entry -> entry.requestId().equals(evidence.requestId()));
entries.addFirst(evidence);
while (entries.size() > MAX_ENTRIES) entries.removeLast();
}
public synchronized List<DdsSqlEvidence> recent() {
return List.copyOf(entries);
}
}

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsProperties; import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsVectorSearchResult; import com.cloudhandson.ddsbackoffice.domain.DdsVectorSearchResult;
import com.cloudhandson.ddsbackoffice.domain.DdsSqlEvidence;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand; import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult; import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorKnowledgeSummary; import com.cloudhandson.vpdbackoffice.domain.vector.VectorKnowledgeSummary;
@@ -21,6 +22,7 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -44,17 +46,20 @@ public class DdsVectorKnowledgeService {
private final OpenAiCompatibleClient embeddingClient; private final OpenAiCompatibleClient embeddingClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DdsProperties properties; private final DdsProperties properties;
private final DdsSqlEvidenceHistory evidenceHistory;
public DdsVectorKnowledgeService( public DdsVectorKnowledgeService(
VectorKnowledgeService commonVectorService, VectorKnowledgeService commonVectorService,
OpenAiCompatibleClient embeddingClient, OpenAiCompatibleClient embeddingClient,
ObjectMapper objectMapper, ObjectMapper objectMapper,
DdsProperties properties DdsProperties properties,
DdsSqlEvidenceHistory evidenceHistory
) { ) {
this.commonVectorService = commonVectorService; this.commonVectorService = commonVectorService;
this.embeddingClient = embeddingClient; this.embeddingClient = embeddingClient;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.properties = properties; this.properties = properties;
this.evidenceHistory = evidenceHistory;
} }
public VectorKnowledgeSummary summary() { public VectorKnowledgeSummary summary() {
@@ -101,7 +106,11 @@ public class DdsVectorKnowledgeService {
connectionProperties.setProperty("oracle.net.CONNECT_TIMEOUT", String.valueOf(timeoutSeconds * 1000)); connectionProperties.setProperty("oracle.net.CONNECT_TIMEOUT", String.valueOf(timeoutSeconds * 1000));
connectionProperties.setProperty("oracle.jdbc.ReadTimeout", String.valueOf(timeoutSeconds * 1000)); connectionProperties.setProperty("oracle.jdbc.ReadTimeout", String.valueOf(timeoutSeconds * 1000));
String sql = "SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score " String requestId = UUID.randomUUID().toString();
String marker = "DDS_EVIDENCE:" + requestId;
String submittedStatement = "지식자료 벡터 검색 · " + properties.vectorObject()
+ " · 유사도순 정렬 · 결과 " + limit + "";
String sql = "SELECT /* " + marker + " */ chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score "
+ "FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri, " + "FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri, "
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score " + "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
+ "FROM " + properties.vectorObject() + " d " + "FROM " + properties.vectorObject() + " d "
@@ -135,10 +144,12 @@ public class DdsVectorKnowledgeService {
String message = rows.isEmpty() String message = rows.isEmpty()
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다." ? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
: rows.size() + "개 검색 단위가 DDS DATA GRANT를 통과했습니다."; : rows.size() + "개 검색 단위가 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult( return new DdsVectorSearchResult(
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode, normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
embeddingModel(mode), true, "DDS 권한으로 검색했습니다.", message, embeddingModel(mode), true, "DDS 권한으로 검색했습니다.", message,
sessionUser, endUser, null, rows); sessionUser, endUser, null, rows, evidence);
} }
} catch (SQLException exception) { } catch (SQLException exception) {
return failure(normalizedUserKey, userLabel, normalizedQuery, mode, return failure(normalizedUserKey, userLabel, normalizedQuery, mode,
@@ -184,7 +195,11 @@ public class DdsVectorKnowledgeService {
connectionProperties.setProperty("oracle.net.CONNECT_TIMEOUT", String.valueOf(timeoutSeconds * 1000)); connectionProperties.setProperty("oracle.net.CONNECT_TIMEOUT", String.valueOf(timeoutSeconds * 1000));
connectionProperties.setProperty("oracle.jdbc.ReadTimeout", String.valueOf(timeoutSeconds * 1000)); connectionProperties.setProperty("oracle.jdbc.ReadTimeout", String.valueOf(timeoutSeconds * 1000));
String sql = "SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score " String requestId = UUID.randomUUID().toString();
String marker = "DDS_EVIDENCE:" + requestId;
String submittedStatement = "지식자료 벡터 검색 · " + properties.vectorObject()
+ " · 유사도순 정렬 · 결과 " + limit + "";
String sql = "SELECT /* " + marker + " */ chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score "
+ "FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri, " + "FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri, "
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score " + "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
+ "FROM " + properties.vectorObject() + " d " + "FROM " + properties.vectorObject() + " d "
@@ -229,10 +244,12 @@ public class DdsVectorKnowledgeService {
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자(" : rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser) + (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
+ ")의 DDS DATA GRANT를 통과했습니다."; + ")의 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult( return new DdsVectorSearchResult(
"token", userLabel, properties.vectorObject(), normalizedQuery, mode, "token", userLabel, properties.vectorObject(), normalizedQuery, mode,
embeddingModel(mode), true, "토큰 기반 DDS 권한으로 검색했습니다.", message, embeddingModel(mode), true, "토큰 기반 DDS 권한으로 검색했습니다.", message,
sessionUser, endUser, null, rows); sessionUser, endUser, null, rows, evidence);
} }
} catch (SQLException exception) { } catch (SQLException exception) {
return failure("token", userLabel, normalizedQuery, mode, return failure("token", userLabel, normalizedQuery, mode,
@@ -251,7 +268,72 @@ public class DdsVectorKnowledgeService {
) { ) {
return new DdsVectorSearchResult( return new DdsVectorSearchResult(
userKey, userLabel, properties.vectorObject(), query, mode, embeddingModel(mode), userKey, userLabel, properties.vectorObject(), query, mode, embeddingModel(mode),
false, title, message, null, null, oracleCode, List.of()); false, title, message, null, null, oracleCode, List.of(), null);
}
private DdsSqlEvidence collectEvidence(Connection connection, String marker, String submittedStatement,
int timeoutSeconds) {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT sql_id, child_number, TO_CHAR(last_active_time, 'YYYY-MM-DD HH24:MI:SS'), executions
FROM v$sql WHERE sql_text LIKE ? ORDER BY last_active_time DESC FETCH FIRST 1 ROW ONLY
""")) {
statement.setQueryTimeout(timeoutSeconds);
statement.setString(1, "%" + marker + "%");
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return DdsSqlEvidence.unavailable(marker, submittedStatement, "실행 SQL ID를 아직 찾지 못했습니다.");
}
String sqlId = result.getString(1);
int child = result.getInt(2);
String vpd = readPredicate(connection, "SELECT predicate FROM v$vpd_policy WHERE sql_id = ?", sqlId, timeoutSeconds);
String plan = readPlanPredicate(connection, sqlId, child, timeoutSeconds);
return new DdsSqlEvidence(true, marker, submittedStatement, sqlId, child, result.getString(3),
result.getLong(4), plan, vpd,
vpd != null ? "VPD 정책 predicate를 확인했습니다."
: "DDS DATA GRANT 경로의 실행 SQL을 확인했습니다.");
}
} catch (SQLException exception) {
return DdsSqlEvidence.unavailable(marker, submittedStatement,
"실행 근거 조회 권한이 없습니다. V$SQL, V$VPD_POLICY, DBMS_XPLAN 권한을 확인하세요.");
}
}
private String readPredicate(Connection connection, String sql, String sqlId, int timeoutSeconds) {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setQueryTimeout(timeoutSeconds);
statement.setString(1, sqlId);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? compact(result.getString(1)) : null;
}
} catch (SQLException ignored) {
return null;
}
}
private String readPlanPredicate(Connection connection, String sqlId, int child, int timeoutSeconds) {
StringBuilder output = new StringBuilder();
try (PreparedStatement statement = connection.prepareStatement(
"SELECT plan_table_output FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(?, ?, 'ALLSTATS LAST PREDICATE'))")) {
statement.setQueryTimeout(timeoutSeconds);
statement.setString(1, sqlId);
statement.setInt(2, child);
try (ResultSet result = statement.executeQuery()) {
boolean predicate = false;
while (result.next()) {
String line = result.getString(1);
if (line != null && line.contains("Predicate Information")) predicate = true;
if (predicate && line != null && output.length() < 1800) output.append(line.trim()).append('\n');
}
}
} catch (SQLException ignored) {
return null;
}
return compact(output.toString());
}
private static String compact(String value) {
if (value == null || value.isBlank()) return null;
return value.replaceAll("\\s+", " ").trim();
} }
private String readSingleValue(Connection connection, String sql, int timeoutSeconds) private String readSingleValue(Connection connection, String sql, int timeoutSeconds)

View File

@@ -4,6 +4,7 @@ import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService; import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionValidationService; import com.cloudhandson.ddsbackoffice.service.DdsProtectionValidationService;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionSchemaService; import com.cloudhandson.ddsbackoffice.service.DdsProtectionSchemaService;
import com.cloudhandson.ddsbackoffice.service.DdsSqlEvidenceHistory;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -18,17 +19,20 @@ public class DdsProtectionController {
private final DdsProtectionStatusService protectionStatusService; private final DdsProtectionStatusService protectionStatusService;
private final DdsProtectionValidationService validationService; private final DdsProtectionValidationService validationService;
private final DdsProtectionSchemaService schemaService; private final DdsProtectionSchemaService schemaService;
private final DdsSqlEvidenceHistory evidenceHistory;
public DdsProtectionController( public DdsProtectionController(
DdsProperties properties, DdsProperties properties,
DdsProtectionStatusService protectionStatusService, DdsProtectionStatusService protectionStatusService,
DdsProtectionValidationService validationService, DdsProtectionValidationService validationService,
DdsProtectionSchemaService schemaService DdsProtectionSchemaService schemaService,
DdsSqlEvidenceHistory evidenceHistory
) { ) {
this.properties = properties; this.properties = properties;
this.protectionStatusService = protectionStatusService; this.protectionStatusService = protectionStatusService;
this.validationService = validationService; this.validationService = validationService;
this.schemaService = schemaService; this.schemaService = schemaService;
this.evidenceHistory = evidenceHistory;
} }
@GetMapping("/dds-protection") @GetMapping("/dds-protection")
@@ -38,6 +42,12 @@ public class DdsProtectionController {
return "vpd-policies"; return "vpd-policies";
} }
@GetMapping("/dds-evidence")
public String evidence(Model model) {
model.addAttribute("evidenceEntries", evidenceHistory.recent());
return "dds-evidence";
}
@GetMapping("/dds-protection/direct") @GetMapping("/dds-protection/direct")
public String directComparison(Model model) { public String directComparison(Model model) {
model.addAttribute("directPaths", protectionStatusService.directComparison()); model.addAttribute("directPaths", protectionStatusService.directComparison());

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('실행 근거')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>실행 근거</h1>
<p class="context-summary">최근 지식자료 검색의 SQL ID와 보호 조건</p>
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">토큰·바인드 값·검색 본문은 기록하지 않습니다. SQL ID와 실행 계획 predicate는 DB 커서 캐시에서 읽으므로 캐시가 비워지면 조회할 수 없습니다.</p></details>
</div>
<section class="content-band" th:if="${#lists.isEmpty(evidenceEntries)}">
<h2>표시할 실행 내역이 없습니다.</h2><p class="section-subtitle">권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.</p>
</section>
<section class="content-band" th:each="entry : ${evidenceEntries}">
<div class="section-heading"><div><h2 th:text="${entry.submittedStatement()}">지식자료 검색</h2><p class="section-subtitle" th:text="${entry.status()}">status</p></div><span class="badge text-bg-secondary" th:text="${entry.available()} ? '실행 확인' : '조회 제한'">status</span></div>
<dl class="protection-evidence-grid"><div><dt>SQL ID</dt><dd><code th:text="${entry.sqlId() ?: '-'}">-</code></dd></div><div><dt>마지막 실행</dt><dd th:text="${entry.lastActiveTime() ?: '-'}">-</dd></div><div><dt>실행 횟수</dt><dd th:text="${entry.executions() ?: '-'}">-</dd></div></dl>
<details class="technical-details"><summary>적용 조건 보기</summary><p th:if="${entry.planPredicate()}"><strong>실행 계획 predicate</strong><br><code th:text="${entry.planPredicate()}">predicate</code></p><p class="mb-0" th:if="${entry.vpdPredicate()}"><strong>VPD predicate</strong><br><code th:text="${entry.vpdPredicate()}">predicate</code></p></details>
</section>
</main>
</body>
</html>

View File

@@ -33,5 +33,18 @@
<summary>기술 정보 보기</summary> <summary>기술 정보 보기</summary>
<p class="mb-0">SESSION_USER: <code th:text="${searchResult.sessionUser() ?: '-'}">-</code> · ORA_END_USER_CONTEXT: <code th:text="${searchResult.endUser() ?: '-'}">-</code></p> <p class="mb-0">SESSION_USER: <code th:text="${searchResult.sessionUser() ?: '-'}">-</code> · ORA_END_USER_CONTEXT: <code th:text="${searchResult.endUser() ?: '-'}">-</code></p>
</details> </details>
<section class="registration-result mt-3" th:if="${searchResult.sqlEvidence() != null}" aria-label="실행 근거">
<div><strong>실행 근거</strong><span th:text="${searchResult.sqlEvidence().status()}">실행 근거</span></div>
<dl th:if="${searchResult.sqlEvidence().available()}">
<div><dt>SQL ID</dt><dd><code th:text="${searchResult.sqlEvidence().sqlId()}">sql id</code></dd></div>
<div><dt>실행 시각</dt><dd th:text="${searchResult.sqlEvidence().lastActiveTime()}">time</dd></div>
<div><dt>실행 횟수</dt><dd th:text="${searchResult.sqlEvidence().executions()}">0</dd></div>
</dl>
<details class="technical-details mt-2"><summary>실행 조건 보기</summary>
<p>요청: <span th:text="${searchResult.sqlEvidence().submittedStatement()}">statement</span></p>
<p th:if="${searchResult.sqlEvidence().planPredicate()}">계획 predicate: <code th:text="${searchResult.sqlEvidence().planPredicate()}">predicate</code></p>
<p class="mb-0" th:if="${searchResult.sqlEvidence().vpdPredicate()}">VPD predicate: <code th:text="${searchResult.sqlEvidence().vpdPredicate()}">predicate</code></p>
</details>
</section>
</div> </div>
</div> </div>

View File

@@ -46,6 +46,7 @@
</div> </div>
<div id="submenu-verification" class="rw-submenu" data-submenu-panel="verification" role="navigation" aria-label="접근 검증 메뉴" hidden> <div id="submenu-verification" class="rw-submenu" data-submenu-panel="verification" role="navigation" aria-label="접근 검증 메뉴" hidden>
<a class="nav-link" href="/dds-protection#direct-check-heading">직접 접근 검증</a> <a class="nav-link" href="/dds-protection#direct-check-heading">직접 접근 검증</a>
<a class="nav-link" href="/dds-evidence">실행 근거</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -135,3 +135,19 @@
- custom VPD Filter까지 정책 함수별로 자동 trace할지는 별도 이슈로 남긴다. - custom VPD Filter까지 정책 함수별로 자동 trace할지는 별도 이슈로 남긴다.
- 운영 DB에서 `ADMIN` 이외의 VPD 소유자 구조를 지원하려면 함수 owner 설정을 명시적으로 추가해야 한다. - 운영 DB에서 `ADMIN` 이외의 VPD 소유자 구조를 지원하려면 함수 owner 설정을 명시적으로 추가해야 한다.
## 13. SQL_ID 실행 증적 확장 — 2026-07-01
재현 SQL만으로는 “이 조건이 실제 DB cursor에 적용됐는가”를 충분히 설명하지 못한다. `/probe` 성공·0행 결과 뒤에 백오피스는 `V$SQL`에서 최근 2분 내 같은 보호 객체를 조회한 cursor를 찾아 SQL_ID와 실행 통계를 표시한다. 이어 `DBMS_XPLAN.DISPLAY_CURSOR(SQL_ID, CHILD_NUMBER, 'ALLSTATS LAST +PREDICATE +ALIAS')`의 Predicate Information을 함께 표시한다.
| 화면 증적 | DB 출처 | 의미 |
|---|---|---|
| DB가 기록한 원문 SQL | `V$SQL.SQL_FULLTEXT` | ORDS가 DB에 제출한 VPD 주입 전 SQL과 SQL_ID |
| DBMS_XPLAN Predicate Information | 해당 SQL_ID/child cursor 실행계획 | DB cursor에 적용된 Access/Filter predicate |
| 권한 조건 재현 SQL | Handler trace 또는 동일 context 정책 함수 | 사람이 WHERE 결합 형태를 읽기 위한 설명용 표현 |
Oracle은 VPD의 내부 rewrite 결과를 별도 최종 SQL 문자열로 `V$SQL`에 보관하지 않는다. 따라서 원문 SQL과 실행계획 predicate를 함께 제시하는 것이 실제 실행에 대한 DB 증적이다. `executions`, `rows_processed`, `elapsed_time`, `buffer_gets`는 cursor 누적값이며 단일 HTTP 요청만의 계측값은 아니다.
최근 SQL 매칭은 대상 객체와 최근 2분 실행 시각으로 고르므로, 같은 객체를 동시에 호출하는 운영 환경에서는 다른 요청 cursor가 선택될 가능성이 있다. 현재 UI는 이를 “최근 실행 증적”으로 명시한다. 요청별 완전 상관이 필요해지면 ORDS Handler에 검증 요청 ID를 주입해 고유 SQL comment/module-action으로 cursor를 추적하는 후속 작업으로 확장한다.
실행 증적 연결에는 최소한 `V$SQL``DBMS_XPLAN.DISPLAY_CURSOR`를 조회할 수 있는 catalog 권한이 필요하다. 해당 권한은 ORDS parsing schema 연결에서 먼저 조회하고, 사용할 수 없으면 백오피스 연결로 한 번 더 시도한다. Autonomous의 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 다른 계정에 위임하지 못할 수 있으므로, 이 경우에는 DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해야 한다. 권한이나 shared pool 보존 시간이 부족하면 권한 검증 결과를 실패시키지 않고 증적 미수집 안내만 표시한다.

View File

@@ -1,6 +1,6 @@
# ORDS/VPD SQL trace # ORDS/VPD SQL trace
권한 결과 확인(`/probe`)은 ORDS 응답의 optional `vpd_predicate`, `effective_sql` 필드를 “토큰 적용 후 SQL” 영역에 표시한다. 권한 결과 확인(`/probe`)은 ORDS 응답의 optional `vpd_predicate`, `effective_sql` 필드를 “권한 조건 재현 SQL” 영역에 표시한다.
```json ```json
{ {
@@ -14,4 +14,8 @@
기존 설치에 trace 권한을 추가하려면 ADMIN으로 `sql/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다. 기존 설치에 trace 권한을 추가하려면 ADMIN으로 `sql/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다.
표시되는 SQL은 Oracle optimizer 실행계획이 아니라, 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다. 기본으로 표시되는 SQL은 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다.
실행 DB가 `V$SQL``DBMS_XPLAN` 조회를 허용하면 `/probe`는 최근 2분 내 같은 보호 객체의 SQL_ID, DB가 기록한 원문 SQL, cursor 누적 통계, Predicate Information도 함께 표시한다. `V$SQL` 원문은 VPD 주입 전 SQL이고, 실제 적용 조건은 `DBMS_XPLAN`의 Predicate Information에서 확인한다. 같은 객체에 동시 요청이 많은 경우에는 최근 cursor 매칭이므로 요청 ID 기반 상관 추적이 필요하다.
Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다.

View File

@@ -0,0 +1,23 @@
-- ============================================================
-- 34_agent_ords_execution_evidence_grant.sql
-- Enable SQL_ID and DBMS_XPLAN evidence for VPD probe requests.
--
-- Run as SYS or a DBA account that can grant SYS.V_$ dynamic-performance
-- views. Typical Autonomous ADMIN accounts cannot delegate these views.
-- The grants are read-only and scoped to the ORDS parsing schema. They do
-- not grant table DML or alter DBMS_RLS enforcement.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
PROMPT === Granting ORDS cursor-evidence read access ===
GRANT SELECT ON V_$SQL TO CB_ORDS;
GRANT SELECT ON V_$SQL_PLAN TO CB_ORDS;
GRANT SELECT ON V_$SQL_PLAN_STATISTICS_ALL TO CB_ORDS;
GRANT SELECT ON V_$SESSION TO CB_ORDS;
GRANT EXECUTE ON DBMS_XPLAN TO CB_ORDS;
PROMPT === ORDS cursor-evidence grants ready ===
PROMPT SQL_ID evidence will appear after the next /probe call.
EXIT;

View File

@@ -16,7 +16,9 @@ public record ProbeResult(
String responseHeaders, String responseHeaders,
String responseBody, String responseBody,
String vpdPredicate, String vpdPredicate,
String effectiveSql String effectiveSql,
SqlExecutionEvidence executionEvidence,
String executionEvidenceMessage
) { ) {
public ProbeResult( public ProbeResult(
@@ -45,6 +47,42 @@ public record ProbeResult(
responseHeaders, responseHeaders,
responseBody, responseBody,
null, 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 null
); );
} }
@@ -99,7 +137,37 @@ public record ProbeResult(
responseHeaders, responseHeaders,
responseBody, responseBody,
predicate, 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(); .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) { public String objectQueryHandlerSource(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId); ProtectedObject object = protectedObjectService.assertEnabled(objectId);
rejectGenericVectorHandler(object); 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.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus; 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.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
@@ -53,6 +54,7 @@ public class OrdsProbeService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SettingService settingService; private final SettingService settingService;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final OrdsMetadataService ordsMetadataService;
private final Clock clock; private final Clock clock;
public OrdsProbeService( public OrdsProbeService(
@@ -64,6 +66,7 @@ public class OrdsProbeService {
ObjectMapper objectMapper, ObjectMapper objectMapper,
SettingService settingService, SettingService settingService,
JdbcTemplate jdbcTemplate, JdbcTemplate jdbcTemplate,
OrdsMetadataService ordsMetadataService,
Clock clock Clock clock
) { ) {
this.tokenService = tokenService; this.tokenService = tokenService;
@@ -74,6 +77,7 @@ public class OrdsProbeService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.settingService = settingService; this.settingService = settingService;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.ordsMetadataService = ordsMetadataService;
this.clock = clock; this.clock = clock;
} }
@@ -142,6 +146,7 @@ public class OrdsProbeService {
prettyHeaders(response.getHeaders()), prettyHeaders(response.getHeaders()),
prettyJson(response.getBody()) prettyJson(response.getBody())
); );
result = attachRecentExecutionEvidence(result, object);
if (isVectorSearchObject(object)) { if (isVectorSearchObject(object)) {
result = addVectorSqlTrace(result, command.bearerToken(), object); result = addVectorSqlTrace(result, command.bearerToken(), object);
} else if (!result.hasSqlTrace()) { } else if (!result.hasSqlTrace()) {
@@ -236,6 +241,114 @@ public class OrdsProbeService {
return text == null || text.isBlank() ? null : text; 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) { private ProbeResult addLocalSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object) {
String predicate = findVpdPredicate(bearerToken, object); String predicate = findVpdPredicate(bearerToken, object);
if (predicate == null || predicate.isBlank()) { if (predicate == null || predicate.isBlank()) {

View File

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

View File

@@ -108,12 +108,51 @@
</div> </div>
</section> </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()}"> <section class="result-section sql-trace-section" th:if="${result.hasSqlTrace()}">
<div class="section-heading compact-heading"> <div class="section-heading compact-heading">
<div> <div>
<h3>토큰 적용 후 SQL</h3> <h3>권한 조건 재현 SQL</h3>
<p class="section-subtitle" <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를 기본 조회문에 합친 형태입니다. ORDS Handler가 반환한 VPD predicate 또는 같은 토큰 컨텍스트를 재현해 조회한 predicate를 기본 조회문에 합친 형태입니다.
</p> </p>
</div> </div>
@@ -159,7 +198,7 @@ ROWNUM &lt;= :row_limit</pre>
</section> </section>
</div> </div>
<p class="form-hint mt-2 mb-0"> <p class="form-hint mt-2 mb-0">
Oracle 내부 optimizer의 실행계획이나 bind 값 치환 결과가 아니라, 이 토큰 컨텍스트에서 VPD 정책 함수가 실제로 반환한 행 조건을 표시합니다. 컬럼 마스킹은 별도 Redaction 정책입니다. 위 SQL은 권한 조건을 읽기 쉽게 재현한 표현입니다. 실제 실행 증적은 SQL_ID와 DBMS_XPLAN 영역에서 확인합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
</p> </p>
</section> </section>

View File

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

View File

@@ -122,4 +122,38 @@ class ProbeResultTest {
assertThat(result.effectiveSql()).contains("ADMIN.DOCUMENTS"); assertThat(result.effectiveSql()).contains("ADMIN.DOCUMENTS");
assertThat(result.rowCount()).isEqualTo(1); 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\""); .doesNotContain("name=\"tokenKeyId\"");
assertThat(result) assertThat(result)
.contains("적용된 사용자와 권한") .contains("적용된 사용자와 권한")
.contains("토큰 적용 후 SQL") .contains("DB 실행 증적")
.contains("권한 조건 재현 SQL")
.contains("set_vpd_context 사용자 컨텍스트") .contains("set_vpd_context 사용자 컨텍스트")
.contains("CB_AGENT_CTX.USER_ID") .contains("CB_AGENT_CTX.USER_ID")
.contains("벡터 유사도 검색 기준") .contains("벡터 유사도 검색 기준")
@@ -243,12 +244,16 @@ class GuidedFlowTemplateTest {
} }
@Test @Test
void probeResultExplainsVectorEffectiveSqlWhenTheHandlerReturnsTrace() throws IOException { void probeResultSeparatesDbExecutionEvidenceFromReconstructedSql() throws IOException {
String result = template("fragments/probe-result.html"); String result = template("fragments/probe-result.html");
String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql")); String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));
assertThat(result) assertThat(result)
.contains("토큰 적용 후 SQL") .contains("DB 실행 증적")
.contains("DB가 기록한 원문 SQL")
.contains("DBMS_XPLAN Predicate Information")
.contains("SQL_ID")
.contains("권한 조건 재현 SQL")
.contains("권한 적용 SQL") .contains("권한 적용 SQL")
.contains("VPD가 추가한 WHERE 조건") .contains("VPD가 추가한 WHERE 조건")
.contains("ALL: 추가 행 필터 없음") .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를 찾지 못했습니다.");
}
}