[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

@@ -1,157 +1,115 @@
# 설계서: ORDS/VPD 권한 적용 SQL trace (#567) # 설계서: ORDS/VPD 실제 실행 감사 증적 (#567)
> **상태**: Approved > **상태**: Implemented
> **작성**: [AI] Architect · **최종수정**: 2026-06-30 > **작성**: [AI] Architect · **최종수정**: 2026-07-01
> **추적성** — Redmine: #567 · 관련 이슈: #561 · 관련 ADR: 없음 > **추적성** — Redmine: #567 · 관련 이슈: #561
> · 구현 파일: `ProbeResult`, `OrdsProbeService`, `OrdsMetadataService`, `probe-result.html`, `ords-handlers.html`, `sql/adb/17_*`, `22_*`, `26_*`, `29_*`, `33_*` > · 구현: `ProbeResult`, `FgaExecutionEvidence`, `OrdsProbeService`, `OrdsMetadataService`, `probe-result.html`, `22_*`, `29_*`, `42_*`, `43_*`
> · 테스트: `ProbeResultTest`, `OrdsMetadataServiceTest`, `GuidedFlowTemplateTest`, `mvn test` > · 검증: `ProbeResultTest`, `ProbeResultTemplateRenderTest`, `mvn test`
## 1. 목적 (Why) ## 목적
권한 결과 확인에서 `set_vpd_context`가 토큰 사용자를 DB 컨텍스트로 설정한 뒤, VPD 정책 함수가 반환한 행 조건이 기본 SELECT에 어떻게 결합되는지 운영자가 직접 확인할 수 있게 한다. 이 기능은 #561의 Handler 흐름 설명을 실행 증거까지 확장한다. 권한 결과 확인에서 보여 주는 SQL을 두 종류로 분리한다.
## 2. 범위 (Scope) | 구분 | 출처 | 용도 |
|---|---|---|
| DB 감사 실행 증적 | Oracle FGA 감사 행 | **실제로 실행된 SQL**과 DB가 기록한 VPD `RLS_INFO` 확인 |
| 참고용 SQL 재현 | Handler 응답/동일 정책 함수 | 사람이 권한 규칙과 SELECT 형태를 이해하는 보조 설명 |
- **포함**: 재현 SQL이나 정책 함수를 다시 호출해 얻은 predicate를 실제 실행 증적으로 주장하지 않는다. `V$SQL`의 최신 cursor 후보를 찾아 추정하는 방식도 요청별 상관관계가 없어 증적 화면의 기준으로 사용하지 않는다.
- ORDS Handler 성공 응답의 `vpd_predicate`, `effective_sql` trace 필드.
- 기존 trace 미적용 Handler를 위한 동일 DB fallback predicate 조회.
- `/probe` 결과의 “토큰 적용 후 SQL” 영역과 trace 미지원 안내.
- 신규/갱신 Handler SQL 및 기존 설치용 `GRANT` 패치 스크립트.
- 토큰 원문 마스킹과 trace 관련 단위/템플릿/소스 테스트.
- **제외 (out of scope)**:
- Oracle optimizer 실행계획, `V$SQL` 조회, bind 값 치환 결과의 수집.
- custom VPD Filter의 임의 SQL 해석 또는 자동 수정.
- 권한 모델/토큰 저장 구조 변경.
- 원문 Bearer Token의 응답·로그·DB 저장.
## 3. 인수조건 (Acceptance Criteria) ## 해결 방식
- [ ] 유효 토큰으로 권한 결과를 조회하면 VPD predicate와 기본 SELECT에 결합한 effective SQL을 확인할 수 있다.
- [ ] 0행 결과도 성공적인 VPD 차단 결과로 trace 표시 대상이 된다.
- [ ] 신규/갱신 object Handler와 기본 VPD/벡터 Handler는 진단 필드를 응답에 포함한다.
- [ ] 기존 Handler가 진단 필드를 반환하지 않아도 같은 DB의 `set_user_by_bearer` 컨텍스트에서 기본 VPD predicate를 재조회한다.
- [ ] trace를 얻을 수 없는 DB 계정·custom Filter·구버전 Handler는 조회 결과를 실패로 바꾸지 않고 적용 방법을 안내한다.
- [ ] trace에는 predicate 표현식과 SQL 구조만 포함하며 Bearer 원문은 포함하지 않는다.
- [ ] 초기화 및 기존 설치용 SQL에 `CB_AGENT_DOC_VPD_FILTER` 실행 권한과 갱신 절차가 명시된다.
- [ ] 관련 단위/템플릿/Handler source 테스트와 전체 Maven 테스트가 통과한다.
## 4. 컨텍스트 & 제약
- ORDS는 공통 `CB_ORDS` 계정으로 접속하고 `cb_ords_handler_pkg.set_vpd_context`가 Bearer key를 `CB_AGENT_CTX`에 매핑한다.
- 기본 VPD 함수 `ADMIN.CB_AGENT_DOC_VPD_FILTER`가 현재 컨텍스트의 권한 규칙으로 predicate를 만든다. 정책 함수의 반환값을 직접 표시하는 것이 행 결과를 다시 계산하는 별도 Java 권한 로직보다 정합성이 높다.
- 기존 Handler는 응답 계약을 바꾸지 않을 수 있으므로, 백오피스 DB 연결에서 같은 Bearer key를 별도 세션 컨텍스트에 설정해 fallback을 수행한다. fallback은 세션 종료 전에 반드시 `clear_user`를 실행한다.
- ORDS 응답 trace가 없거나 fallback 권한이 없으면 권한 결과 자체는 그대로 반환한다. trace는 관측성 보조 기능이며 접근 통제 경로가 아니다.
- `ADMIN`은 현재 PoC의 VPD 함수/컨텍스트 소유자다. 다른 소유자 구조는 이번 범위에서 자동 추론하지 않는다.
## 5. 아키텍처 개요
```text ```text
[Probe form: bearerToken + object] [권한 결과 확인]
│ UUID 생성
[ORDS Handler: set_vpd_context] X-VPD-Probe-Id HTTP header
┌──────┴────────┐
│ │ [ORDS Handler]
▼ ▼ set_vpd_context(auth, probe_id)
[DBMS_RLS SELECT] [same-context trace] │ └─ DBMS_SESSION.SET_IDENTIFIER(probe_id)
│ │
└──────┬────────┘ [VPD 보호 SELECT]
[ORDS JSON: items + vpd_predicate + effective_sql] ├─ DBMS_RLS가 정책 predicate 적용
▼ (trace absent only) [DBMS_FGA SELECT 감사]
[Backoffice DB fallback: set_user_by_bearer → filter → clear_user] SQL_TEXT + RLS_INFO + CLIENT_ID 저장
[ProbeResult → “토큰 적용 후 SQL”] [Backoffice]
UNIFIED_AUDIT_TRAIL(ADB)에서 CLIENT_IDENTIFIER=probe_id로 한 행 조회
DBA_FGA_AUDIT_TRAIL은 전통 FGA DB fallback
DB 감사 실행 증적 화면
``` ```
- I/O 경계: `OrdsProbeService.findVpdPredicate`와 ORDS HTTP 호출은 외부 I/O다. `ProbeResult.hasSqlTrace/withSqlTrace`와 SQL 문자열 조합은 표현/변환 책임으로 둔다. - `CLIENT_ID`는 백오피스가 매 probe마다 생성한 UUID다. 토큰, 사용자명, 권한 값은 넣지 않는다.
- 안전 경계: predicate 조회 실패는 조회 실패가 아니며 trace만 생략한다. Bearer header는 기존처럼 마스킹된 request detail만 노출한다. - ORDS connection pool 재사용 시 남는 식별자가 없도록 `set_vpd_context` 시작과 `clear_vpd_context`에서 `DBMS_SESSION.CLEAR_IDENTIFIER`를 호출한다.
- FGA는 `SQL_TEXT`와 VPD 정책명·predicate가 담긴 `RLS_INFO`를 DB 감사 trail에 저장한다. 이 Autonomous DB에서는 `UNIFIED_AUDIT_TRAIL`을 사용하며, 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 조회한다.
- FGA 감사 행이 없으면 기존의 권한 결과 성공/0행 상태를 실패로 바꾸지 않는다. 다만 “실제 실행 증적 없음”으로 명확히 표시한다.
## 6. 데이터 모델 ## 데이터 계약
- `ProbeResult.vpdPredicate`: VPD 정책 함수가 현재 토큰 컨텍스트에서 반환한 predicate 문자열(nullable). ### ORDS 요청
- `ProbeResult.effectiveSql`: Handler의 기본 SELECT와 predicate/row limit을 결합한 확인용 SQL(nullable).
- ORDS JSON 성공 응답에 두 문자열을 optional root field로 추가한다. `items`/`rows` 기존 배열 계약은 유지한다.
- trace SQL은 `:row_limit` bind 표기를 유지하며 실제 Bearer 값이나 context 값은 문자열에 치환하지 않는다.
## 7. 함수 명세 (Function Specs) ```http
X-VPD-Probe-Id: 8db9fc64-c71b-4b9e-9514-2fcaa355d9f3
Authorization: Bearer <masked>
```
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | `X-VPD-Probe-Id`는 일반 object handler, 기본 문서 handler, vector handler 모두 `:probe_id`로 bind한다.
|------|-----------|----------------|------|------|-----------|-------|
| `ProbeResult.hasSqlTrace` | effective SQL 존재 여부를 판단 | `boolean hasSqlTrace()` | result fields | boolean | blank/null이면 false | 단순 |
| `ProbeResult.withSqlTrace` | 기존 결과에 trace 두 필드를 불변으로 부착 | `ProbeResult withSqlTrace(String, String)` | predicate, SQL | 새 `ProbeResult` | 없음 | 단순 |
| `OrdsProbeService.runProbe` | ORDS 결과를 파싱하고 필요한 경우 trace fallback을 연결 | `ProbeResult runProbe(ProbeCommand)` | token/object/limit | ProbeResult | 기존 상태 분류 유지 | **복잡** |
| `parseSuccess` | ORDS JSON 배열과 optional trace 필드 파싱 | `ProbeResult parseSuccess(...)` | body, objectId, HTTP evidence | ProbeResult | 배열 누락 시 예외 | 단순 |
| `traceValue` | JSON scalar trace 필드 정규화 | `String traceValue(JsonNode, String)` | root/field | nullable String | 비 scalar/blank이면 null | 단순 |
| `addLocalSqlTrace` | predicate와 보호 객체 컬럼으로 effective SQL 생성 | `ProbeResult addLocalSqlTrace(...)` | result/token/object | trace 부착 결과 | DB/컬럼 조회 실패 시 원 결과 | **복잡** |
| `findVpdPredicate` | 같은 DB 연결에서 컨텍스트 설정→predicate 조회→컨텍스트 정리 | `String findVpdPredicate(String, ProtectedObject)` | bearer/object | nullable predicate | 권한/DB 오류는 null | **복잡** |
| `executeContextSetter` | DB 세션에 Bearer 기반 사용자 컨텍스트 설정 | `void executeContextSetter(Connection, String)` | connection/token | 없음 | SQLException | 단순 |
| `clearContext` | fallback 세션의 사용자 컨텍스트 제거 | `void clearContext(Connection)` | connection | 없음 | cleanup 오류 무시 | 단순 |
| `objectQuerySource` | 생성 Handler에 trace metadata 출력 블록을 포함 | `String objectQuerySource(ProtectedObject,List<String>)` | object/columns | PL/SQL source | 식별자/컬럼 검증은 호출부 | **복잡** |
복잡 함수 상세는 `fn-addLocalSqlTrace.md`, `fn-findVpdPredicate.md`에 둔다. `runProbe`의 기본 상태 분류 계약은 기존 #557 설계를 계승한다. ### 화면 모델
## 8. 흐름 / 알고리즘 `FgaExecutionEvidence`는 다음 값을 보관한다.
1. ORDS base URL, token, 보호 객체를 기존 검증 절차로 확인한다. - 감사 시각 `EVENT_TIMESTAMP_UTC` (`EXTENDED_TIMESTAMP` fallback)
2. ORDS POST를 호출한다. 갱신된 Handler는 `set_vpd_context` 직후 동일 VPD 함수를 호출해 trace를 만들고, `items`와 함께 JSON으로 반환한다. - DB 실행 사용자 `DBUSERNAME` (`DB_USER` fallback)
3. 백오피스는 `items`/`rows`를 기존 방식으로 파싱한다. - 요청 식별자 `CLIENT_IDENTIFIER` (`CLIENT_ID` fallback)
4. trace가 없고 일반 object Handler이면 백오피스 DB의 한 연결에서 `set_user_by_bearer`를 호출한다. - 문장 유형 `ACTION_NAME` (`STATEMENT_TYPE` fallback)
5. 같은 연결에서 `SELECT admin.cb_agent_doc_vpd_filter(?, ?) FROM dual`을 수행한다. - 실제 SQL `SQL_TEXT`
6. `finally` 경로에서 `clear_user`를 호출하고 연결을 pool에 반환한다. - 실제 VPD 정보 `RLS_INFO`
7. 보호 객체의 등록 컬럼과 predicate를 이용해 `SELECT ... FROM OWNER.OBJECT o WHERE (...) AND ROWNUM ...` 형태를 만든다.
8. 화면은 결과 행·사용자 권한 뒤에 predicate와 effective SQL을 표시한다. trace가 없으면 ORDS/DB 적용 절차를 안내한다.
## 9. 엣지케이스 & 에러 처리 `ProbeResult`는 이 값을 별도 필드로 보관한다. 기존 `vpdPredicate`/`effectiveSql`은 “참고용 SQL 재현”에만 사용한다.
- token/ORDS 오류: 기존 `ProbeStatus`와 HTTP evidence 처리만 수행하며 fallback을 실행하지 않는다. ## 설치와 운영 절차
- 0행: `VPD_DENY_EMPTY_RESULT`를 유지하고 trace를 표시한다.
- ORDS 신규 trace가 있고 local DB trace가 불가능한 경우: ORDS 응답 trace를 그대로 사용한다.
- 기존 Handler·DB 계정에 trace 권한이 없는 경우: 결과 성공/0행을 유지하고 trace 미지원 안내를 표시한다.
- fallback context setter가 실패한 경우: 반드시 cleanup을 시도하고 trace는 null로 둔다.
- vector object: 전용 Handler의 복합 vector SQL을 일반 `SELECT`로 오표시하지 않도록 local generic fallback을 생략한다. 갱신된 vector Handler가 자체 trace를 반환하면 표시한다.
- custom VPD Filter: 기본 함수 반환값을 custom Filter의 실제 predicate로 주장하지 않는다. trace가 없으면 별도 Filter 확인을 안내한다.
- predicate가 길어도 DB 함수 반환 한도(`VARCHAR2(32767)`)와 화면 `<pre>`로 처리한다.
## 10. 테스트 계획 1. `ADMIN`으로 [42_agent_ords_fga_execution_audit.sql](../../../sql/adb/42_agent_ords_fga_execution_audit.sql)을 실행해 활성 보호 객체마다 FGA `SELECT` 정책을 만든다.
2. `CB_ORDS`로 [22_agent_ords_security_ords_handler_setup.sql](../../../sql/adb/22_agent_ords_security_ords_handler_setup.sql), [29_agent_ords_vector_search_ords.sql](../../../sql/adb/29_agent_ords_vector_search_ords.sql), [43_agent_ords_probe_id_handler_patch.sql](../../../sql/adb/43_agent_ords_probe_id_handler_patch.sql)을 순서대로 실행한다.
3. 새 권한 결과 확인을 실행한다. 이전 요청에는 `CLIENT_ID`가 없으므로 소급해 매칭하지 않는다.
4. 백오피스 실행 계정이 Autonomous의 `UNIFIED_AUDIT_TRAIL`을 조회할 수 있어야 한다. 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 사용한다. 일반 계정이면 감사 조회 권한을 가진 전용 observer 계정을 사용한다.
- `ProbeResultTest`: trace 부착 후 기존 rows/status 보존, blank trace 미표시. ## 인수조건
- `OrdsMetadataServiceTest`: 생성 object Handler에 `set_vpd_context`, default VPD function, JSON trace 필드와 컬럼 SQL이 포함되는지 확인.
- `GuidedFlowTemplateTest`: 결과 화면/Handler 안내/초기화 SQL/기존 설치 grant 및 vector trace source 확인.
- `mvn test`: 전체 단위·템플릿 테스트.
- 운영 smoke: 신규 토큰으로 `/probe`에서 HR/SELF/ALL 결과를 각각 실행하고 predicate의 `DEPT_CODE`, `OWNER_EMP_NO`, `1 = 1` 또는 `1 = 0` 차이를 확인한다. 토큰 원문이 response body/log에 없는지 확인한다.
## 11. 리스크 & 대안 검토 - [ ] probe마다 새로운 request ID가 ORDS header에 전달된다.
- [ ] Handler가 request ID를 `CLIENT_IDENTIFIER`로 설정하고 종료 시 제거한다.
- [ ] FGA가 활성 보호 객체의 `SELECT`를 감사한다.
- [ ] 화면은 같은 request ID의 `SQL_TEXT``RLS_INFO`만 실제 실행 증적으로 보여 준다.
- [ ] FGA 행이 없거나 읽기 권한이 없을 때 다른 cursor나 재현 predicate를 실제 값처럼 대신 보여 주지 않는다.
- [ ] 재현 SQL은 참고용으로만 표시되며 실제 증적과 시각적으로 구분된다.
- [ ] Bearer token 원문은 header, 감사 correlation ID, 화면 어느 곳에도 기록하지 않는다.
- `V$SQL`/`DBMS_XPLAN` 조회는 connection pool의 다른 세션·cursor·권한 문제 때문에 결과가 불안정하다. 정책 함수 반환값을 같은 context에서 표시하는 방식을 선택한다. ## 실패 처리
- Java에서 permission table을 다시 계산하는 방식은 그룹 상속·DENY 우선순위·custom policy와 어긋날 수 있다. DB의 정책 함수 자체를 trace source로 사용한다.
- Handler 응답 계약을 강제로 변경하면 기존 Handler가 실패할 수 있다. optional response fields와 non-failing fallback을 사용한다.
- fallback에서 context cleanup을 놓치면 pool connection 간 사용자 혼선이 발생한다. `finally` cleanup을 고정하고 cleanup 오류는 결과를 덮지 않는다.
## 12. 미해결 질문 (Open Questions) | 상황 | 화면 동작 |
|---|---|
| FGA 정책 미적용 | 권한 결과는 표시하고, FGA 적용 안내를 표시 |
| ORDS Handler가 구버전 | request ID가 DB에 설정되지 않아 해당 요청의 감사 행을 표시하지 않음 |
| 감사 조회 권한 없음 | 권한 결과는 유지하고, audit trail 권한 안내 표시 |
| `RLS_INFO` 비어 있음 | `SQL_TEXT`는 표시하고 audit 설정 확인 안내 표시 |
| 0행 결과 | VPD 차단 결과와 FGA `SELECT` 감사 행을 함께 표시 |
- custom VPD Filter까지 정책 함수별로 자동 trace할지는 별도 이슈로 남긴다. ## 리스크와 선택 근거
- 운영 DB에서 `ADMIN` 이외의 VPD 소유자 구조를 지원하려면 함수 owner 설정을 명시적으로 추가해야 한다.
## 13. SQL_ID 실행 증적 확장 — 2026-07-01 - VPD policy function 안에서 별도 DML 로그를 남기지 않는다. 정책 함수에는 DB 상태 변경 제약이 있어, 감사 책임을 FGA에 둔다.
- `V$SQL`은 shared pool cursor cache이며 보존·권한·동시성에 따라 해당 요청을 신뢰성 있게 찾을 수 없다. 운영 진단 보조로는 가능하지만 요청 증적의 기준으로는 부적합하다.
- FGA audit trail은 보존 정책을 운영에서 정해야 한다. 장기 감사가 필요하면 Unified Audit 보존·전송 정책을 별도로 설정한다.
재현 SQL만으로는 “이 조건이 실제 DB cursor에 적용됐는가”를 충분히 설명하지 못한다. `/probe` 성공·0행 결과 뒤에 백오피스는 `V$SQL`에서 시간 조건 없이 마지막 활성 cursor 10건을 먼저 읽고, Java에서 `SQL_FULLTEXT`에 보호 객체명이 있는 cursor만 고른다. DB에서 SQL 텍스트를 `LIKE`로 필터링하지 않아 ORDS Handler의 PL/SQL·공백·긴 SQL 표현 때문에 놓치는 문제를 줄인다. 이어 `DBMS_XPLAN.DISPLAY_CURSOR(SQL_ID, CHILD_NUMBER, 'ALLSTATS LAST +PREDICATE +ALIAS')`의 Predicate Information을 함께 표시한다. ## 테스트 계획
| 화면 증적 | DB 출처 | 의미 | - 단위: `ProbeResult`가 FGA 증적과 참고용 재현 SQL을 서로 다른 필드로 유지한다.
|---|---|---| - 템플릿: FGA `SQL_TEXT`, `RLS_INFO`, `CLIENT_ID`가 표시되고 V$SQL 후보 목록은 표시되지 않는다.
| DB가 기록한 원문 SQL | `V$SQL.SQL_FULLTEXT` | ORDS가 DB에 제출한 VPD 주입 전 SQL과 SQL_ID | - 통합: ADMIN/CB_ORDS 설치 후 각 보호 객체에 probe 실행 → `UNIFIED_AUDIT_TRAIL.CLIENT_IDENTIFIER`가 화면 요청 ID와 같은지, `RLS_INFO`에 해당 VPD 정책이 있는지 확인한다.
| DBMS_XPLAN Predicate Information | 해당 SQL_ID/child cursor 실행계획 | DB cursor에 적용된 Access/Filter predicate |
| 권한 조건 재현 SQL | Handler trace 또는 동일 context 정책 함수 | 사람이 WHERE 결합 형태를 읽기 위한 설명용 표현 |
Oracle은 VPD의 내부 rewrite 결과를 별도 최종 SQL 문자열로 `V$SQL`에 보관하지 않는다. 따라서 원문 SQL과 실행계획 predicate를 함께 제시하는 것이 실제 실행에 대한 DB 증적이다. `executions`, `rows_processed`, `elapsed_time`, `buffer_gets`는 cursor 누적값이며 단일 HTTP 요청만의 계측값은 아니다.
화면에서는 설명용 표현을 **실행 요청 SQL (재현)**, cursor에서 읽은 원문을 **실제 DB cursor SQL**로 구분한다. `V$SQL` 권한이 없는 환경에서도 재현 SQL은 표시하고, 실제 cursor 증적이 없다는 이유를 별도로 표시한다.
최근 SQL 매칭은 최신 10건과 보호 객체명으로 고르므로, 같은 객체를 동시에 호출하는 운영 환경에서는 다른 요청 cursor가 선택될 가능성이 있다. 현재 UI는 이를 “최근 실행 증적”으로 명시한다. 요청별 완전 상관이 필요해지면 ORDS Handler에 검증 요청 ID를 주입해 고유 SQL comment/module-action으로 cursor를 추적하는 후속 작업으로 확장한다.
매칭에 실패해도 후보를 버리지 않는다. 화면은 최신 10건의 `SQL_ID`, child cursor, parsing schema, 마지막 실행 시각, 원문 SQL을 그대로 보이며, Java가 보호 객체명과 일치시킨 후보에는 별도 배지를 표시한다. 운영자는 이 목록에서 실제 Handler SQL의 형태를 직접 확인할 수 있다.
실행 증적 연결에는 최소한 `V$SQL``DBMS_XPLAN.DISPLAY_CURSOR`를 조회할 수 있는 catalog 권한이 필요하다. 증적은 ORDS parsing schema가 아니라 화면을 표시하는 `BACKOFFICE_DB_USERNAME` 연결에서 조회한다. 실제 SQL 실행 계정과 진단 조회 계정은 달라도 된다. Autonomous의 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 다른 계정에 위임하지 못할 수 있으므로, 이 경우에는 DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해야 한다. cursor 미발견과 catalog 권한 미보유는 UI에서 서로 다른 안내로 표시하며, 어느 경우도 권한 검증 결과를 실패시키지 않는다.

View File

@@ -19,7 +19,10 @@ SET FEEDBACK ON
PROMPT === Creating CB_ORDS handler package === PROMPT === Creating CB_ORDS handler package ===
CREATE OR REPLACE PACKAGE cb_ords_handler_pkg AUTHID DEFINER AS CREATE OR REPLACE PACKAGE cb_ords_handler_pkg AUTHID DEFINER AS
PROCEDURE set_vpd_context(p_authorization IN VARCHAR2); PROCEDURE set_vpd_context(
p_authorization IN VARCHAR2,
p_probe_id IN VARCHAR2 DEFAULT NULL
);
FUNCTION set_vpd_context_sql(p_authorization IN VARCHAR2) RETURN NUMBER; FUNCTION set_vpd_context_sql(p_authorization IN VARCHAR2) RETURN NUMBER;
PROCEDURE clear_vpd_context; PROCEDURE clear_vpd_context;
END; END;
@@ -38,12 +41,19 @@ CREATE OR REPLACE PACKAGE BODY cb_ords_handler_pkg AS
RETURN TRIM(SUBSTR(v_auth, 8)); RETURN TRIM(SUBSTR(v_auth, 8));
END; END;
PROCEDURE set_vpd_context(p_authorization IN VARCHAR2) AS PROCEDURE set_vpd_context(
p_authorization IN VARCHAR2,
p_probe_id IN VARCHAR2 DEFAULT NULL
) AS
v_bearer_key VARCHAR2(4000); v_bearer_key VARCHAR2(4000);
BEGIN BEGIN
DBMS_SESSION.CLEAR_IDENTIFIER;
admin.cb_agent_ctx_pkg.clear_user; admin.cb_agent_ctx_pkg.clear_user;
v_bearer_key := extract_bearer_key(p_authorization); v_bearer_key := extract_bearer_key(p_authorization);
admin.cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_key); admin.cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_key);
IF TRIM(p_probe_id) IS NOT NULL THEN
DBMS_SESSION.SET_IDENTIFIER(SUBSTR(TRIM(p_probe_id), 1, 64));
END IF;
END; END;
FUNCTION set_vpd_context_sql(p_authorization IN VARCHAR2) RETURN NUMBER AS FUNCTION set_vpd_context_sql(p_authorization IN VARCHAR2) RETURN NUMBER AS
@@ -55,6 +65,7 @@ CREATE OR REPLACE PACKAGE BODY cb_ords_handler_pkg AS
PROCEDURE clear_vpd_context AS PROCEDURE clear_vpd_context AS
BEGIN BEGIN
admin.cb_agent_ctx_pkg.clear_user; admin.cb_agent_ctx_pkg.clear_user;
DBMS_SESSION.CLEAR_IDENTIFIER;
END; END;
END; END;
/ /
@@ -93,7 +104,7 @@ DECLARE
v_vpd_predicate VARCHAR2(32767); v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767); v_effective_sql VARCHAR2(32767);
BEGIN BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header); cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
-- The predicate below is the value returned by the same DBMS_RLS policy -- The predicate below is the value returned by the same DBMS_RLS policy
-- function that protects ADMIN.CB_V_SEARCH_DOCUMENTS. -- function that protects ADMIN.CB_V_SEARCH_DOCUMENTS.
@@ -156,6 +167,16 @@ END;
p_param_type => 'STRING', p_param_type => 'STRING',
p_access_method => 'IN' p_access_method => 'IN'
); );
ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.security',
p_pattern => 'vpd/documents',
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( ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.security', p_module_name => 'cb.agent.security',
p_pattern => 'vpd/documents', p_pattern => 'vpd/documents',

View File

@@ -53,7 +53,7 @@ DECLARE
v_vpd_predicate VARCHAR2(32767); v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767); v_effective_sql VARCHAR2(32767);
BEGIN BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header); cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
BEGIN BEGIN
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS'); v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS');
@@ -163,6 +163,17 @@ END;
p_access_method => 'IN' p_access_method => 'IN'
); );
ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.vector',
p_pattern => 'search',
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( ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.vector', p_module_name => 'cb.agent.vector',
p_pattern => 'search', p_pattern => 'search',

View File

@@ -0,0 +1,70 @@
-- ============================================================
-- 42_agent_ords_fga_execution_audit.sql
-- Durable, request-correlated execution evidence for VPD probes.
--
-- Run as the protected-object owner (ADMIN in this PoC), after the
-- VPD policies and CB_PROTECTED_OBJECT entries have been applied.
--
-- Each enabled protected object gets an FGA SELECT policy. Autonomous Oracle
-- writes SQL_TEXT and RLS_INFO to UNIFIED_AUDIT_TRAIL (traditional databases
-- can expose the same information through DBA_FGA_AUDIT_TRAIL). The ORDS handler puts the
-- backoffice-generated X-VPD-Probe-Id into CLIENT_ID, so the UI can read
-- exactly this request's audit row rather than guess from V$SQL.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
PROMPT === Applying FGA execution-audit policies ===
DECLARE
v_policy_name VARCHAR2(128);
BEGIN
FOR r IN (
SELECT object_id, owner, object_name
FROM cb_protected_object
WHERE enabled_yn = 'Y'
ORDER BY object_id
) LOOP
IF UPPER(r.owner) <> USER THEN
RAISE_APPLICATION_ERROR(
-20842,
'Run this script as protected object owner ' || r.owner
|| ' (current user: ' || USER || ')'
);
END IF;
v_policy_name := 'CB_VPD_EXEC_AUDIT_' || r.object_id;
BEGIN
DBMS_FGA.DROP_POLICY(
object_schema => r.owner,
object_name => r.object_name,
policy_name => v_policy_name
);
EXCEPTION
WHEN OTHERS THEN
-- ORA-28102: policy does not exist. Other errors must stop setup.
IF SQLCODE <> -28102 THEN
RAISE;
END IF;
END;
DBMS_FGA.ADD_POLICY(
object_schema => r.owner,
object_name => r.object_name,
policy_name => v_policy_name,
audit_condition => NULL,
audit_column => NULL,
statement_types => 'SELECT',
audit_trail => DBMS_FGA.DB + DBMS_FGA.EXTENDED,
enable => TRUE
);
END LOOP;
END;
/
COMMIT;
PROMPT === FGA policies are ready ===
PROMPT Run 22, 29 and 43 as CB_ORDS, then run a new permission-result probe.
PROMPT The resulting row is read from UNIFIED_AUDIT_TRAIL by CLIENT_IDENTIFIER.
EXIT;

View File

@@ -0,0 +1,59 @@
-- ============================================================
-- 43_agent_ords_probe_id_handler_patch.sql
-- Adds X-VPD-Probe-Id to existing generic cb.object.query handlers.
--
-- Run as CB_ORDS after 22_agent_ords_security_ords_handler_setup.sql.
-- The dedicated document/vector handlers are updated by 22/29 themselves.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
PROMPT === Patching generic object-query handlers for FGA correlation ===
DECLARE
v_source CLOB;
BEGIN
FOR r IN (
SELECT m.name AS module_name,
t.uri_template,
h.method,
h.source
FROM user_ords_handlers h
JOIN user_ords_templates t ON t.id = h.template_id
JOIN user_ords_modules m ON m.id = t.module_id
WHERE m.name = 'cb.object.query'
AND h.method = 'POST'
AND INSTR(h.source, 'cb_ords_handler_pkg.set_vpd_context(:auth_header)') > 0
) LOOP
v_source := REPLACE(
r.source,
'cb_ords_handler_pkg.set_vpd_context(:auth_header)',
'cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)'
);
ORDS.DEFINE_HANDLER(
p_module_name => r.module_name,
p_pattern => r.uri_template,
p_method => r.method,
p_source_type => ORDS.source_type_plsql,
p_source => v_source,
p_items_per_page => 0
);
ORDS.DEFINE_PARAMETER(
p_module_name => r.module_name,
p_pattern => r.uri_template,
p_method => r.method,
p_name => 'X-VPD-Probe-Id',
p_bind_variable_name => 'probe_id',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
END LOOP;
END;
/
COMMIT;
PROMPT === Generic handler patch complete ===
EXIT;

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, String effectiveSql,
SqlExecutionEvidence executionEvidence, SqlExecutionEvidence executionEvidence,
String executionEvidenceMessage, String executionEvidenceMessage,
List<SqlExecutionCandidate> executionCandidates List<SqlExecutionCandidate> executionCandidates,
FgaExecutionEvidence fgaExecutionEvidence,
String fgaExecutionEvidenceMessage
) { ) {
public ProbeResult( public ProbeResult(
@@ -51,7 +53,9 @@ public record ProbeResult(
null, null,
null, null,
null, null,
List.of() List.of(),
null,
null
); );
} }
@@ -86,7 +90,9 @@ public record ProbeResult(
effectiveSql, effectiveSql,
null, null,
null, null,
List.of() List.of(),
null,
null
); );
} }
@@ -143,7 +149,9 @@ public record ProbeResult(
sql, sql,
executionEvidence, executionEvidence,
executionEvidenceMessage, executionEvidenceMessage,
executionCandidates executionCandidates,
fgaExecutionEvidence,
fgaExecutionEvidenceMessage
); );
} }
@@ -180,7 +188,9 @@ public record ProbeResult(
effectiveSql, effectiveSql,
evidence, evidence,
unavailableMessage, 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(); 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() { public String title() {
return switch (status) { return switch (status) {
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다."; case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";

View File

@@ -221,6 +221,16 @@ public class OrdsMetadataService {
p_param_type => 'STRING', p_param_type => 'STRING',
p_access_method => 'IN' 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( ORDS.DEFINE_PARAMETER(
p_module_name => ?, p_module_name => ?,
p_pattern => ?, p_pattern => ?,
@@ -239,6 +249,7 @@ public class OrdsMetadataService {
moduleName, template, moduleName, template,
moduleName, template, source, moduleName, template, source,
moduleName, template, moduleName, template,
moduleName, template,
moduleName, template); moduleName, template);
protectedObjectService.updateOrdsPath(object.objectId(), ordsPath); protectedObjectService.updateOrdsPath(object.objectId(), ordsPath);
return new OrdsObjectHandlerResult(object.objectId(), ordsPath, moduleName, template); return new OrdsObjectHandlerResult(object.objectId(), ordsPath, moduleName, template);
@@ -259,7 +270,7 @@ public class OrdsMetadataService {
v_vpd_predicate VARCHAR2(32767); v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767); v_effective_sql VARCHAR2(32767);
BEGIN 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 -- This is the same predicate function invoked by DBMS_RLS for the
-- SELECT below. It is returned only as diagnostic metadata. -- 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.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus; 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.SqlExecutionCandidate;
import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionEvidence; import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionEvidence;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
@@ -25,6 +26,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
@@ -149,6 +151,8 @@ public class OrdsProbeService {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(command.bearerToken()); headers.setBearerAuth(command.bearerToken());
headers.setContentType(MediaType.APPLICATION_JSON); 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() String requestBody = command.requestBody() == null || command.requestBody().isBlank()
? "{}" ? "{}"
: command.requestBody().trim(); : command.requestBody().trim();
@@ -164,7 +168,7 @@ public class OrdsProbeService {
prettyHeaders(response.getHeaders()), prettyHeaders(response.getHeaders()),
prettyJson(response.getBody()) prettyJson(response.getBody())
); );
result = attachRecentExecutionEvidence(result, object); result = attachFgaExecutionEvidence(result, object, probeRequestId);
if (isVectorSearchObject(object)) { if (isVectorSearchObject(object)) {
result = addVectorSqlTrace(result, command.bearerToken(), object); result = addVectorSqlTrace(result, command.bearerToken(), object);
} else if (!result.hasSqlTrace()) { } else if (!result.hasSqlTrace()) {
@@ -259,6 +263,114 @@ public class OrdsProbeService {
return text == null || text.isBlank() ? null : text; 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 * V$SQL keeps the statement submitted by ORDS, not Oracle's internally
* rewritten VPD text. DBMS_XPLAN is therefore the authoritative place to * 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"> <section class="product-help-section product-help-sql">
<h2>실행 SQL은 어디에서 확인하나요?</h2> <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> </section>
</div> </div>
</details> </details>

View File

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

View File

@@ -66,7 +66,7 @@
</form> </form>
<details class="explanation-details explanation-warning mt-3"> <details class="explanation-details explanation-warning mt-3">
<summary>기본 Handler 구성과 수정 가이드 보기</summary> <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> </details>
</section> </section>

View File

@@ -8,7 +8,7 @@
<h1>접근 검증</h1> <h1>접근 검증</h1>
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <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> </details>
</div> </div>

View File

@@ -180,4 +180,35 @@ class ProbeResultTest {
assertThat(result.hasExecutionCandidates()).isTrue(); assertThat(result.hasExecutionCandidates()).isTrue();
assertThat(result.executionCandidates()).hasSize(1); assertThat(result.executionCandidates()).hasSize(1);
} }
@Test
void keepsAuditedExecutionEvidenceSeparateFromTheReconstructedSql() {
FgaExecutionEvidence evidence = new FgaExecutionEvidence(
"2026-07-01T10:00:00Z",
"CB_ORDS",
"probe-123",
"SELECT",
"SELECT d.doc_id FROM ADMIN.CB_V_SEARCH_DOCUMENTS d",
"CB_AGENT_DOC_POLICY: (DEPT_CODE = 'HR')"
);
ProbeResult result = new ProbeResult(
ProbeStatus.SUCCESS,
List.of("DOC_ID"),
List.of(Map.of("DOC_ID", 1)),
1,
List.of(),
null,
null,
null,
null,
null,
null
).withSqlTrace("DEPT_CODE = SYS_CONTEXT(...)", "SELECT ... WHERE (...)")
.withFgaExecutionEvidence(evidence, null);
assertThat(result.hasFgaExecutionEvidence()).isTrue();
assertThat(result.fgaExecutionEvidence().clientId()).isEqualTo("probe-123");
assertThat(result.fgaExecutionEvidence().hasRlsInfo()).isTrue();
assertThat(result.effectiveSql()).contains("WHERE");
}
} }

View File

@@ -45,7 +45,7 @@ class OrdsMetadataServiceTest {
String source = service.objectQueryHandlerSource(7L); String source = service.objectQueryHandlerSource(7L);
assertThat(source) assertThat(source)
.contains("cb_ords_handler_pkg.set_vpd_context(:auth_header)") .contains("cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)")
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')") .contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
.contains("v_vpd_predicate") .contains("v_vpd_predicate")
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)") .contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)")

View File

@@ -109,14 +109,14 @@ class GuidedFlowTemplateTest {
.doesNotContain("name=\"tokenKeyId\""); .doesNotContain("name=\"tokenKeyId\"");
assertThat(result) assertThat(result)
.contains("적용된 사용자와 권한") .contains("적용된 사용자와 권한")
.contains("DB 실행 증적") .contains("DB 감사 실행 증적")
.contains("최근 DB cursor 10건") .contains("DB가 감사한 실행 SQL")
.contains("실행 요청 SQL") .contains("DB가 감사한 VPD predicate")
.contains("실제 DB cursor SQL") .contains("참고용 SQL 재현")
.contains("set_vpd_context 사용자 컨텍스트") .contains("set_vpd_context 사용자 컨텍스트")
.contains("CB_AGENT_CTX.USER_ID") .contains("CB_AGENT_CTX.USER_ID")
.contains("벡터 유사도 검색 기준") .contains("벡터 유사도 검색 기준")
.contains("역할 기반 권한 필터 (VPD)") .contains("역할 기반 권한 필터 (재현)")
.contains("VECTOR_DISTANCE") .contains("VECTOR_DISTANCE")
.contains("벡터 검색 Top-K") .contains("벡터 검색 Top-K")
.contains("벡터 거리 (SCORE)") .contains("벡터 거리 (SCORE)")
@@ -265,18 +265,18 @@ class GuidedFlowTemplateTest {
String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql")); String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));
assertThat(result) assertThat(result)
.contains("DB 실행 증적") .contains("DB 감사 실행 증적")
.contains("DB가 기록한 원문 SQL") .contains("DB가 감사한 실행 SQL")
.contains("DBMS_XPLAN Predicate Information") .contains("DB가 감사한 VPD predicate")
.contains("SQL_ID") .contains("RLS_INFO")
.contains("실행 요청 SQL") .contains("참고용 SQL 재현")
.contains("실제 DB cursor SQL") .contains("SQL (재현)")
.contains("실행 요청 SQL (재현)") .contains("VPD 조건 (재현)")
.contains("VPD가 추가한 WHERE 조건")
.contains("ALL: 추가 행 필터 없음") .contains("ALL: 추가 행 필터 없음")
.contains("기본 whitelist 역할"); .contains("기본 whitelist 역할");
assertThat(vectorSql) assertThat(vectorSql)
.contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)") .contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)")
.contains("cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)")
.contains("IF TRIM(v_vpd_predicate) <> '1 = 1' THEN") .contains("IF TRIM(v_vpd_predicate) <> '1 = 1' THEN")
.contains("' AND (' || v_vpd_predicate || ')'"); .contains("' AND (' || v_vpd_predicate || ')'");
} }

View File

@@ -2,9 +2,9 @@ package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.probe.FgaExecutionEvidence;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import com.cloudhandson.vpdbackoffice.domain.probe.SqlExecutionCandidate;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -17,7 +17,7 @@ import org.thymeleaf.templateresolver.FileTemplateResolver;
class ProbeResultTemplateRenderTest { class ProbeResultTemplateRenderTest {
@Test @Test
void rendersRecentSqlEvidenceUnavailableMessageAsTextNotAsABoolean() { void rendersFgaEvidenceAndUnavailableMessageAsTextNotAsABoolean() {
var resolver = new FileTemplateResolver(); var resolver = new FileTemplateResolver();
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/"); resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
resolver.setSuffix(".html"); resolver.setSuffix(".html");
@@ -38,11 +38,14 @@ class ProbeResultTemplateRenderTest {
"{}", "{}",
"{}", "{}",
"{}" "{}"
).withExecutionEvidence(null, ).withFgaExecutionEvidence(new FgaExecutionEvidence(
"최근 cursor를 찾지 못했습니다.", "2026-07-01T15:00:00Z",
List.of(new SqlExecutionCandidate( "CB_ORDS",
"4f2jz7n2k0s3p", 0, "CB_ORDS", "8db9fc64-c71b-4b9e-9514-2fcaa355d9f3",
"SELECT * FROM ADMIN.CB_V_SEARCH_DOCUMENTS", "2026-07-01T15:00", true))); "SELECT",
"SELECT * FROM ADMIN.CB_V_SEARCH_DOCUMENTS",
"CB_AGENT_DOC_POLICY: (DEPT_CODE = 'HR')"
), null);
var context = new Context(Locale.KOREAN); var context = new Context(Locale.KOREAN);
context.setVariable("result", result); context.setVariable("result", result);
context.setVariable("vectorSearch", false); context.setVariable("vectorSearch", false);
@@ -52,9 +55,9 @@ class ProbeResultTemplateRenderTest {
String rendered = engine.process("fragments/probe-result", context); String rendered = engine.process("fragments/probe-result", context);
assertThat(rendered) assertThat(rendered)
.contains("최근 cursor를 찾지 못했습니다.") .contains("DB 감사 실행 증적")
.contains("최근 DB cursor 10건") .contains("8db9fc64-c71b-4b9e-9514-2fcaa355d9f3")
.contains("4f2jz7n2k0s3p") .contains("SELECT * FROM ADMIN.CB_V_SEARCH_DOCUMENTS")
.contains("보호 대상 일치"); .contains("CB_AGENT_DOC_POLICY: (DEPT_CODE = &#39;HR&#39;)");
} }
} }