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 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>