[Developer] #567 record actual VPD execution with FGA

This commit is contained in:
devmrko
2026-07-01 16:02:03 +09:00
parent 57d77d2df2
commit ebcce66bf7
17 changed files with 532 additions and 220 deletions

View File

@@ -0,0 +1,20 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
/**
* Durable, database-generated FGA evidence for a protected-object SELECT.
* SQL text and RLS information come from Oracle's audit trail, not from a
* re-evaluation of the VPD policy function.
*/
public record FgaExecutionEvidence(
String eventAt,
String dbUser,
String clientId,
String statementType,
String sqlText,
String rlsInfo
) {
public boolean hasRlsInfo() {
return rlsInfo != null && !rlsInfo.isBlank();
}
}

View File

@@ -19,7 +19,9 @@ public record ProbeResult(
String effectiveSql,
SqlExecutionEvidence executionEvidence,
String executionEvidenceMessage,
List<SqlExecutionCandidate> executionCandidates
List<SqlExecutionCandidate> executionCandidates,
FgaExecutionEvidence fgaExecutionEvidence,
String fgaExecutionEvidenceMessage
) {
public ProbeResult(
@@ -51,7 +53,9 @@ public record ProbeResult(
null,
null,
null,
List.of()
List.of(),
null,
null
);
}
@@ -86,7 +90,9 @@ public record ProbeResult(
effectiveSql,
null,
null,
List.of()
List.of(),
null,
null
);
}
@@ -143,7 +149,9 @@ public record ProbeResult(
sql,
executionEvidence,
executionEvidenceMessage,
executionCandidates
executionCandidates,
fgaExecutionEvidence,
fgaExecutionEvidenceMessage
);
}
@@ -180,7 +188,9 @@ public record ProbeResult(
effectiveSql,
evidence,
unavailableMessage,
candidates == null ? List.of() : List.copyOf(candidates)
candidates == null ? List.of() : List.copyOf(candidates),
fgaExecutionEvidence,
fgaExecutionEvidenceMessage
);
}
@@ -188,6 +198,37 @@ public record ProbeResult(
return executionCandidates != null && !executionCandidates.isEmpty();
}
public boolean hasFgaExecutionEvidence() {
return fgaExecutionEvidence != null && fgaExecutionEvidence.sqlText() != null
&& !fgaExecutionEvidence.sqlText().isBlank();
}
public ProbeResult withFgaExecutionEvidence(
FgaExecutionEvidence evidence,
String unavailableMessage
) {
return new ProbeResult(
status,
columns,
rows,
rowCount,
maskedColumns,
errorCode,
errorMessage,
requestHeaders,
requestPayload,
responseHeaders,
responseBody,
vpdPredicate,
effectiveSql,
executionEvidence,
executionEvidenceMessage,
executionCandidates,
evidence,
unavailableMessage
);
}
public String title() {
return switch (status) {
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";

View File

@@ -221,6 +221,16 @@ public class OrdsMetadataService {
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_name => 'X-VPD-Probe-Id',
p_bind_variable_name => 'probe_id',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
@@ -239,6 +249,7 @@ public class OrdsMetadataService {
moduleName, template,
moduleName, template, source,
moduleName, template,
moduleName, template,
moduleName, template);
protectedObjectService.updateOrdsPath(object.objectId(), ordsPath);
return new OrdsObjectHandlerResult(object.objectId(), ordsPath, moduleName, template);
@@ -259,7 +270,7 @@ public class OrdsMetadataService {
v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767);
BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header);
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
-- This is the same predicate function invoked by DBMS_RLS for the
-- SELECT below. It is returned only as diagnostic metadata.

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.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import com.cloudhandson.vpdbackoffice.domain.probe.FgaExecutionEvidence;
import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionCandidate;
import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionEvidence;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
@@ -25,6 +26,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.http.HttpEntity;
@@ -149,6 +151,8 @@ public class OrdsProbeService {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(command.bearerToken());
headers.setContentType(MediaType.APPLICATION_JSON);
String probeRequestId = UUID.randomUUID().toString();
headers.set("X-VPD-Probe-Id", probeRequestId);
String requestBody = command.requestBody() == null || command.requestBody().isBlank()
? "{}"
: command.requestBody().trim();
@@ -164,7 +168,7 @@ public class OrdsProbeService {
prettyHeaders(response.getHeaders()),
prettyJson(response.getBody())
);
result = attachRecentExecutionEvidence(result, object);
result = attachFgaExecutionEvidence(result, object, probeRequestId);
if (isVectorSearchObject(object)) {
result = addVectorSqlTrace(result, command.bearerToken(), object);
} else if (!result.hasSqlTrace()) {
@@ -259,6 +263,114 @@ public class OrdsProbeService {
return text == null || text.isBlank() ? null : text;
}
/**
* FGA records are durable database audit evidence. Unlike the optional
* V$SQL diagnostic view, SQL_TEXT and RLS_INFO originate from the SELECT
* that Oracle actually audited.
*/
private ProbeResult attachFgaExecutionEvidence(
ProbeResult result,
ProtectedObject object,
String probeRequestId
) {
String owner = object.owner().toUpperCase(Locale.ROOT);
String name = object.objectName().toUpperCase(Locale.ROOT);
FgaExecutionEvidence evidence = findUnifiedFgaEvidence(owner, name, probeRequestId);
if (evidence == null) {
evidence = findTraditionalFgaEvidence(owner, name, probeRequestId);
}
if (evidence == null) {
return result.withFgaExecutionEvidence(null,
"이 요청의 DB 감사 행을 찾지 못했습니다. FGA 실행 감사 정책과 ORDS Handler의 X-VPD-Probe-Id 연동을 적용한 뒤 다시 검증하세요.");
}
String message = evidence.hasRlsInfo() ? null
: "FGA 감사 SQL은 기록됐지만 RLS_INFO가 비어 있습니다. 감사 정책과 DB audit trail 설정을 확인하세요.";
return result.withFgaExecutionEvidence(evidence, message);
}
private FgaExecutionEvidence findUnifiedFgaEvidence(
String owner,
String name,
String probeRequestId
) {
return queryFgaEvidence("""
SELECT event_timestamp_utc AS event_at,
dbusername AS db_user,
client_identifier AS client_id,
action_name AS statement_type,
sql_text,
rls_info
FROM (
SELECT event_timestamp_utc,
dbusername,
client_identifier,
action_name,
sql_text,
rls_info
FROM unified_audit_trail
WHERE object_schema = ?
AND object_name = ?
AND action_name = 'SELECT'
AND client_identifier = ?
AND fga_policy_name IS NOT NULL
ORDER BY event_timestamp_utc DESC
)
WHERE ROWNUM = 1
""", owner, name, probeRequestId);
}
private FgaExecutionEvidence findTraditionalFgaEvidence(
String owner,
String name,
String probeRequestId
) {
return queryFgaEvidence("""
SELECT extended_timestamp AS event_at,
db_user,
client_id,
statement_type,
sql_text,
rls_info
FROM (
SELECT extended_timestamp,
db_user,
client_id,
statement_type,
sql_text,
rls_info
FROM dba_fga_audit_trail
WHERE object_schema = ?
AND object_name = ?
AND statement_type = 'SELECT'
AND client_id = ?
ORDER BY extended_timestamp DESC
)
WHERE ROWNUM = 1
""", owner, name, probeRequestId);
}
private FgaExecutionEvidence queryFgaEvidence(String sql, Object... arguments) {
try {
return jdbcTemplate.query(sql, resultSet -> {
if (!resultSet.next()) {
return null;
}
return new FgaExecutionEvidence(
resultSet.getTimestamp("event_at") == null
? null : resultSet.getTimestamp("event_at").toInstant().toString(),
resultSet.getString("db_user"),
resultSet.getString("client_id"),
resultSet.getString("statement_type"),
resultSet.getString("sql_text"),
resultSet.getString("rls_info")
);
}, arguments);
} catch (RuntimeException exception) {
log.debug("FGA audit trail query is unavailable: {}", exception.getMessage());
return null;
}
}
/**
* V$SQL keeps the statement submitted by ORDS, not Oracle's internally
* rewritten VPD text. DBMS_XPLAN is therefore the authoritative place to

View File

@@ -81,7 +81,7 @@
<section class="product-help-section product-help-sql">
<h2>실행 SQL은 어디에서 확인하나요?</h2>
<p><strong>실행 요청 SQL</strong>현재 사용자 컨텍스트와 VPD 조건을 결합한 읽기용 재현 SQL입니다. 실제 DB cursor가 기록한 원문 SQL과 Predicate Information은 <code>V$SQL</code>·<code>DBMS_XPLAN</code> 권한이 있을 때 접근 검증 결과에서 함께 표시됩니다. Oracle은 VPD가 내부적으로 붙인 최종 rewrite 문자열 자체를 별도 SQL 텍스트로 보관하지 않으므로, 실제 적용 근거는 실행계획의 Predicate Information으로 확인합니다.</p>
<p><strong>DB 감사 실행 증적</strong>FGA가 남긴 실제 실행 SQL과 VPD RLS 정보를 보여줍니다. 화면의 SQL 재현은 설정을 이해하기 위한 참고용이며, 실행 증적과 구분합니다.</p>
</section>
</div>
</details>

View File

@@ -108,73 +108,48 @@
</div>
</section>
<section class="result-section" th:if="${result.hasExecutionEvidence()}">
<section class="result-section" th:if="${result.hasFgaExecutionEvidence()}">
<div class="section-heading compact-heading">
<div>
<h3>DB 실행 증적</h3>
<p class="section-subtitle">DB가 보관한 최신 cursor 10건에서 같은 보호 대상을 찾아 SQL_ID로 확인한 결과입니다.</p>
<h3>DB 감사 실행 증적</h3>
<p class="section-subtitle">이번 요청 ID와 일치하는 DB 감사 행입니다. 화면에서 재계산한 값이 아닙니다.</p>
</div>
<span class="badge text-bg-success" th:text="${'SQL_ID ' + result.executionEvidence().sqlId()}">SQL_ID</span>
<span class="badge text-bg-success">FGA</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>
<div><dt>감사 시각</dt><dd th:text="${result.fgaExecutionEvidence().eventAt()} ?: '-'">2026-07-01T10:00:00Z</dd></div>
<div><dt>DB 실행 사용자</dt><dd th:text="${result.fgaExecutionEvidence().dbUser()} ?: '-'">CB_ORDS</dd></div>
<div><dt>요청 식별자</dt><dd><code th:text="${result.fgaExecutionEvidence().clientId()} ?: '-'">request-id</code></dd></div>
<div><dt>문장 종류</dt><dd th:text="${result.fgaExecutionEvidence().statementType()} ?: '-'">SELECT</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>
<h3>DB가 감사한 실행 SQL</h3>
<p class="form-hint">FGA의 SQL_TEXT입니다.</p>
<pre th:text="${result.fgaExecutionEvidence().sqlText()}">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 class="probe-exchange" data-sql-trace-field="fga_rls_info" th:if="${result.fgaExecutionEvidence().hasRlsInfo()}">
<h3>DB가 감사한 VPD predicate</h3>
<p class="form-hint">FGA의 RLS_INFO입니다. 적용된 VPD 정책명과 predicate가 기록됩니다.</p>
<pre th:text="${result.fgaExecutionEvidence().rlsInfo()}">RLS_INFO</pre>
</section>
</div>
<div class="alert alert-light mt-3 mb-0" th:if="${result.executionEvidenceMessage() != null}"
th:text="${result.executionEvidenceMessage()}"></div>
</section>
<section class="result-section" th:if="${result.hasExecutionCandidates()}">
<div class="section-heading compact-heading">
<div>
<h3>최근 DB cursor 10건</h3>
<p class="section-subtitle">백오피스 DB 연결이 읽은 최신 cursor입니다. 보호 대상과 일치한 SQL은 초록 배지로 표시합니다.</p>
</div>
<span class="badge text-bg-light" th:text="${result.executionCandidates().size() + '건'}">10건</span>
</div>
<article class="probe-exchange mt-3" th:each="candidate : ${result.executionCandidates()}">
<div class="section-heading compact-heading">
<div>
<h3 th:text="${'SQL_ID ' + candidate.sqlId()}">SQL_ID</h3>
<p class="form-hint mb-0"
th:text="${'최근 실행: ' + (candidate.lastActiveAt() ?: '-') + ' · Parsing schema: ' + (candidate.parsingSchema() ?: '-') + ' · Child cursor: ' + candidate.childNumber()}">최근 실행</p>
</div>
<span class="badge"
th:classappend="${candidate.matchesProtectedObject()} ? ' text-bg-success' : ' text-bg-light'"
th:text="${candidate.matchesProtectedObject()} ? '보호 대상 일치' : '다른 SQL'">다른 SQL</span>
</div>
<pre class="mt-2 mb-0" th:text="${candidate.originalSql()} ?: '-'">SELECT ...</pre>
</article>
<div class="alert alert-light mt-3 mb-0" th:if="${result.fgaExecutionEvidenceMessage() != null}"
th:text="${result.fgaExecutionEvidenceMessage()}"></div>
</section>
<section class="result-section sql-trace-section" th:if="${result.hasSqlTrace()}">
<div class="section-heading compact-heading">
<div>
<h3>실행 요청 SQL</h3>
<h3>참고용 SQL 재현</h3>
<p class="section-subtitle"
th:text="${vectorSearch ? 'VECTOR_DISTANCE 검색과 현재 사용자의 역할 기반 VPD 조건합한 읽기용 SQL입니다.' : '현재 사용자 컨텍스트에서 계산한 VPD 조건을 ORDS 조회문에 결합한 읽기용 SQL입니다.'}">
현재 사용자 컨텍스트에서 계산한 VPD 조건을 ORDS 조회문에 결합한 읽기용 SQL입니다.
th:text="${vectorSearch ? 'VECTOR_DISTANCE 검색과 권한 규칙합한 참고용 표현입니다.' : '현재 권한 규칙을 조합한 참고용 표현입니다.'}">
현재 권한 규칙을 조합한 참고용 표현입니다.
</p>
</div>
<span class="badge text-bg-light">DBMS_RLS predicate</span>
<span class="badge text-bg-light">참고용</span>
</div>
<div class="probe-exchange-grid mt-3">
<section class="probe-exchange" data-sql-trace-field="vpd_context" th:if="${tokenContext}">
@@ -205,24 +180,24 @@ ROWNUM &lt;= :row_limit</pre>
<p class="form-hint mb-0">이 부분이 검색어 벡터와 저장 벡터의 거리 계산입니다. 아래에는 실제로 등록된 역할 기반 행 접근 조건만 표시됩니다.</p>
</section>
<section class="probe-exchange" data-sql-trace-field="vpd_predicate">
<h3 th:text="${vectorSearch ? '역할 기반 권한 필터 (VPD)' : 'VPD가 추가한 WHERE 조건'}">VPD가 추가한 WHERE 조건</h3>
<h3 th:text="${vectorSearch ? '역할 기반 권한 필터 (재현)' : 'VPD 조건 (재현)'}">VPD 조건 (재현)</h3>
<pre th:if="${result.vpdPredicate() == '1 = 1'}">1 = 1 (ALL: 추가 행 필터 없음)</pre>
<pre th:unless="${result.vpdPredicate() == '1 = 1'}" th:text="${result.vpdPredicate()}">(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))</pre>
<p class="form-hint mb-0" th:if="${vectorSearch}">선택한 사용자의 직접 역할·그룹 상속 역할에 연결된 permission rule에서 계산됩니다. 기본 whitelist 역할의 ALL은 추가 행 필터 없이 조회를 허용하고, 실제로 TAG·부서 조건을 등록한 역할만 그 조건이 SQL에 들어갑니다. 권한이 없으면 <code>1 = 0</code>입니다.</p>
</section>
<section class="probe-exchange" data-sql-trace-field="effective_sql">
<h3>실행 요청 SQL (재현)</h3>
<h3>SQL (재현)</h3>
<pre th:text="${result.effectiveSql()}">SELECT ... WHERE (...) AND ROWNUM &lt;= ...</pre>
</section>
</div>
<p class="form-hint mt-2 mb-0">
SQL은 현재 요청의 권한 조건을 읽기 쉽게 재현한 표현입니다. 실제 DB cursor 원문과 적용 predicate는 SQL_ID·DBMS_XPLAN 증적에서 확인합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
영역은 권한 규칙을 읽기 쉽게 재현한 표현일 뿐, 실행 증적이 아닙니다. 실제 SQL과 VPD predicate는 위의 DB 감사 실행 증적(FGA)으로 확인합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
</p>
</section>
<div class="alert alert-light mb-0" th:if="${result.successLike() and !result.hasExecutionEvidence() and result.executionEvidenceMessage() != null}">
<strong>실제 DB cursor SQL</strong>
<span th:text="${result.executionEvidenceMessage()}">최근 SQL_ID를 찾지 못했습니다.</span>
<div class="alert alert-light mb-0" th:if="${result.successLike() and !result.hasFgaExecutionEvidence() and result.fgaExecutionEvidenceMessage() != null}">
<strong>DB 감사 실행 증적</strong>
<span th:text="${result.fgaExecutionEvidenceMessage()}">감사 행을 찾지 못했습니다.</span>
</div>
<div class="alert alert-light mb-0" th:if="${result.successLike() and !result.hasSqlTrace()}">

View File

@@ -66,7 +66,7 @@
</form>
<details class="explanation-details explanation-warning mt-3">
<summary>기본 Handler 구성과 수정 가이드 보기</summary>
<p><code>cb_ords_handler_pkg.set_vpd_context(:auth_header)</code>로 토큰의 사용자·역할 컨텍스트를 넣고, 선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다. 이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다. 컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/permissions">권한 관리의 원문 표시 허용 컬럼</a>에서 별도로 설정합니다.</p>
<p><code>cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)</code>로 토큰의 사용자·역할 컨텍스트와 검증 요청 식별자를 넣고, 선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다. 이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다. 컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/permissions">권한 관리의 원문 표시 허용 컬럼</a>에서 별도로 설정합니다.</p>
</details>
</section>

View File

@@ -8,7 +8,7 @@
<h1>접근 검증</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>한 사용자의 토큰으로 실제 데이터를 요청해, 설계한 권한이 DB에서 그대로 적용되는지 확인합니다. 결과의 실행 요청 SQL은 현재 컨텍스트를 결합한 읽기용 표현이며, DB cursor가 기록한 원문 SQL은 <code>V$SQL</code>·<code>DBMS_XPLAN</code> 진단 권한이 있을 때만 함께 표시됩니다.</p>
<p>한 사용자의 토큰으로 실제 데이터를 요청해, 설계한 권한이 DB에서 그대로 적용되는지 확인합니다. 결과는 DB 감사 로그에 남은 실행 SQL과 VPD 정보를 보여주며, SQL 재현은 설정을 읽기 위한 참고용으로 구분합니다.</p>
</details>
</div>