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());