feat: show DDS SQL execution evidence

This commit is contained in:
devmrko
2026-07-01 13:39:38 +09:00
parent f15b453aad
commit 07c090f6b0
8 changed files with 180 additions and 8 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 endUser,
Integer oracleCode,
List<Map<String, Object>> rows
List<Map<String, Object>> rows,
DdsSqlEvidence sqlEvidence
) {
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.domain.DdsVectorSearchResult;
import com.cloudhandson.ddsbackoffice.domain.DdsSqlEvidence;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorKnowledgeSummary;
@@ -21,6 +22,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@@ -44,17 +46,20 @@ public class DdsVectorKnowledgeService {
private final OpenAiCompatibleClient embeddingClient;
private final ObjectMapper objectMapper;
private final DdsProperties properties;
private final DdsSqlEvidenceHistory evidenceHistory;
public DdsVectorKnowledgeService(
VectorKnowledgeService commonVectorService,
OpenAiCompatibleClient embeddingClient,
ObjectMapper objectMapper,
DdsProperties properties
DdsProperties properties,
DdsSqlEvidenceHistory evidenceHistory
) {
this.commonVectorService = commonVectorService;
this.embeddingClient = embeddingClient;
this.objectMapper = objectMapper;
this.properties = properties;
this.evidenceHistory = evidenceHistory;
}
public VectorKnowledgeSummary summary() {
@@ -101,7 +106,11 @@ public class DdsVectorKnowledgeService {
connectionProperties.setProperty("oracle.net.CONNECT_TIMEOUT", 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, "
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
+ "FROM " + properties.vectorObject() + " d "
@@ -135,10 +144,12 @@ public class DdsVectorKnowledgeService {
String message = rows.isEmpty()
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
: rows.size() + "개 검색 단위가 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult(
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
embeddingModel(mode), true, "DDS 권한으로 검색했습니다.", message,
sessionUser, endUser, null, rows);
sessionUser, endUser, null, rows, evidence);
}
} catch (SQLException exception) {
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.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, "
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
+ "FROM " + properties.vectorObject() + " d "
@@ -229,10 +244,12 @@ public class DdsVectorKnowledgeService {
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
+ ")의 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult(
"token", userLabel, properties.vectorObject(), normalizedQuery, mode,
embeddingModel(mode), true, "토큰 기반 DDS 권한으로 검색했습니다.", message,
sessionUser, endUser, null, rows);
sessionUser, endUser, null, rows, evidence);
}
} catch (SQLException exception) {
return failure("token", userLabel, normalizedQuery, mode,
@@ -251,7 +268,72 @@ public class DdsVectorKnowledgeService {
) {
return new DdsVectorSearchResult(
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)

View File

@@ -4,6 +4,7 @@ import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionValidationService;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionSchemaService;
import com.cloudhandson.ddsbackoffice.service.DdsSqlEvidenceHistory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,17 +19,20 @@ public class DdsProtectionController {
private final DdsProtectionStatusService protectionStatusService;
private final DdsProtectionValidationService validationService;
private final DdsProtectionSchemaService schemaService;
private final DdsSqlEvidenceHistory evidenceHistory;
public DdsProtectionController(
DdsProperties properties,
DdsProtectionStatusService protectionStatusService,
DdsProtectionValidationService validationService,
DdsProtectionSchemaService schemaService
DdsProtectionSchemaService schemaService,
DdsSqlEvidenceHistory evidenceHistory
) {
this.properties = properties;
this.protectionStatusService = protectionStatusService;
this.validationService = validationService;
this.schemaService = schemaService;
this.evidenceHistory = evidenceHistory;
}
@GetMapping("/dds-protection")
@@ -38,6 +42,12 @@ public class DdsProtectionController {
return "vpd-policies";
}
@GetMapping("/dds-evidence")
public String evidence(Model model) {
model.addAttribute("evidenceEntries", evidenceHistory.recent());
return "dds-evidence";
}
@GetMapping("/dds-protection/direct")
public String directComparison(Model model) {
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>
<p class="mb-0">SESSION_USER: <code th:text="${searchResult.sessionUser() ?: '-'}">-</code> · ORA_END_USER_CONTEXT: <code th:text="${searchResult.endUser() ?: '-'}">-</code></p>
</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>

View File

@@ -46,6 +46,7 @@
</div>
<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-evidence">실행 근거</a>
</div>
</div>
</div>