From 07c090f6b0c972aa33100dccb261ea51e0f7e358 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 1 Jul 2026 13:39:38 +0900 Subject: [PATCH] feat: show DDS SQL execution evidence --- .../ddsbackoffice/domain/DdsSqlEvidence.java | 19 ++++ .../domain/DdsVectorSearchResult.java | 3 +- .../service/DdsSqlEvidenceHistory.java | 24 +++++ .../service/DdsVectorKnowledgeService.java | 94 +++++++++++++++++-- .../web/DdsProtectionController.java | 12 ++- .../resources/templates/dds-evidence.html | 22 +++++ .../fragments/dds-vector-search-result.html | 13 +++ .../resources/templates/fragments/layout.html | 1 + 8 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsSqlEvidenceHistory.java create mode 100644 dds-backoffice/src/main/resources/templates/dds-evidence.html diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java new file mode 100644 index 0000000..b1ab8b4 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java @@ -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); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java index 480e07e..4bffe8f 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java @@ -17,7 +17,8 @@ public record DdsVectorSearchResult( String sessionUser, String endUser, Integer oracleCode, - List> rows + List> rows, + DdsSqlEvidence sqlEvidence ) { public DdsVectorSearchResult { diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsSqlEvidenceHistory.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsSqlEvidenceHistory.java new file mode 100644 index 0000000..5ff417c --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsSqlEvidenceHistory.java @@ -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 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 recent() { + return List.copyOf(entries); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java index 5a3b2dd..25bdc3d 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java @@ -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) diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java index cc7127b..d480426 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java @@ -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()); diff --git a/dds-backoffice/src/main/resources/templates/dds-evidence.html b/dds-backoffice/src/main/resources/templates/dds-evidence.html new file mode 100644 index 0000000..b5bad8f --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/dds-evidence.html @@ -0,0 +1,22 @@ + + + + + +
+
+

실행 근거

+

최근 지식자료 검색의 SQL ID와 보호 조건

+
도움말

토큰·바인드 값·검색 본문은 기록하지 않습니다. SQL ID와 실행 계획 predicate는 DB 커서 캐시에서 읽으므로 캐시가 비워지면 조회할 수 없습니다.

+
+
+

표시할 실행 내역이 없습니다.

권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.

+
+
+

지식자료 검색

status

status
+
SQL ID
-
마지막 실행
-
실행 횟수
-
+
적용 조건 보기

실행 계획 predicate
predicate

VPD predicate
predicate

+
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html b/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html index 318c1ed..7551966 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html +++ b/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html @@ -33,5 +33,18 @@ 기술 정보 보기

SESSION_USER: - · ORA_END_USER_CONTEXT: -

+
+
실행 근거실행 근거
+
+
SQL ID
sql id
+
실행 시각
time
+
실행 횟수
0
+
+
실행 조건 보기 +

요청: statement

+

계획 predicate: predicate

+

VPD predicate: predicate

+
+
diff --git a/dds-backoffice/src/main/resources/templates/fragments/layout.html b/dds-backoffice/src/main/resources/templates/fragments/layout.html index 3f12fcf..09a1a39 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/layout.html +++ b/dds-backoffice/src/main/resources/templates/fragments/layout.html @@ -46,6 +46,7 @@