[Developer] #567 expose VPD effective SQL trace
This commit is contained in:
137
docs/design/567-ords-vpd-sql-trace/README.md
Normal file
137
docs/design/567-ords-vpd-sql-trace/README.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 설계서: ORDS/VPD 권한 적용 SQL trace (#567)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-30
|
||||||
|
> **추적성** — Redmine: #567 · 관련 이슈: #561 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `ProbeResult`, `OrdsProbeService`, `OrdsMetadataService`, `probe-result.html`, `ords-handlers.html`, `sql/adb/17_*`, `22_*`, `26_*`, `29_*`, `33_*`
|
||||||
|
> · 테스트: `ProbeResultTest`, `OrdsMetadataServiceTest`, `GuidedFlowTemplateTest`, `mvn test`
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
권한 결과 확인에서 `set_vpd_context`가 토큰 사용자를 DB 컨텍스트로 설정한 뒤, VPD 정책 함수가 반환한 행 조건이 기본 SELECT에 어떻게 결합되는지 운영자가 직접 확인할 수 있게 한다. 이 기능은 #561의 Handler 흐름 설명을 실행 증거까지 확장한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**:
|
||||||
|
- 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
|
||||||
|
[Probe form: bearerToken + object]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ORDS Handler: set_vpd_context]
|
||||||
|
│
|
||||||
|
┌──────┴────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
[DBMS_RLS SELECT] [same-context trace]
|
||||||
|
│ │
|
||||||
|
└──────┬────────┘
|
||||||
|
▼
|
||||||
|
[ORDS JSON: items + vpd_predicate + effective_sql]
|
||||||
|
│
|
||||||
|
▼ (trace absent only)
|
||||||
|
[Backoffice DB fallback: set_user_by_bearer → filter → clear_user]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ProbeResult → “토큰 적용 후 SQL”]
|
||||||
|
```
|
||||||
|
|
||||||
|
- I/O 경계: `OrdsProbeService.findVpdPredicate`와 ORDS HTTP 호출은 외부 I/O다. `ProbeResult.hasSqlTrace/withSqlTrace`와 SQL 문자열 조합은 표현/변환 책임으로 둔다.
|
||||||
|
- 안전 경계: predicate 조회 실패는 조회 실패가 아니며 trace만 생략한다. Bearer header는 기존처럼 마스킹된 request detail만 노출한다.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
- `ProbeResult.vpdPredicate`: VPD 정책 함수가 현재 토큰 컨텍스트에서 반환한 predicate 문자열(nullable).
|
||||||
|
- `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)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `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. 흐름 / 알고리즘
|
||||||
|
|
||||||
|
1. ORDS base URL, token, 보호 객체를 기존 검증 절차로 확인한다.
|
||||||
|
2. ORDS POST를 호출한다. 갱신된 Handler는 `set_vpd_context` 직후 동일 VPD 함수를 호출해 trace를 만들고, `items`와 함께 JSON으로 반환한다.
|
||||||
|
3. 백오피스는 `items`/`rows`를 기존 방식으로 파싱한다.
|
||||||
|
4. trace가 없고 일반 object Handler이면 백오피스 DB의 한 연결에서 `set_user_by_bearer`를 호출한다.
|
||||||
|
5. 같은 연결에서 `SELECT admin.cb_agent_doc_vpd_filter(?, ?) FROM dual`을 수행한다.
|
||||||
|
6. `finally` 경로에서 `clear_user`를 호출하고 연결을 pool에 반환한다.
|
||||||
|
7. 보호 객체의 등록 컬럼과 predicate를 이용해 `SELECT ... FROM OWNER.OBJECT o WHERE (...) AND ROWNUM ...` 형태를 만든다.
|
||||||
|
8. 화면은 결과 행·사용자 권한 뒤에 predicate와 effective SQL을 표시한다. trace가 없으면 ORDS/DB 적용 절차를 안내한다.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
|
||||||
|
- 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. 테스트 계획
|
||||||
|
|
||||||
|
- `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. 리스크 & 대안 검토
|
||||||
|
|
||||||
|
- `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)
|
||||||
|
|
||||||
|
- custom VPD Filter까지 정책 함수별로 자동 trace할지는 별도 이슈로 남긴다.
|
||||||
|
- 운영 DB에서 `ADMIN` 이외의 VPD 소유자 구조를 지원하려면 함수 owner 설정을 명시적으로 추가해야 한다.
|
||||||
73
docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md
Normal file
73
docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 함수 설계서: `addLocalSqlTrace` (#567)
|
||||||
|
|
||||||
|
> **부모 설계서**: ./README.md · **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test`
|
||||||
|
|
||||||
|
## 1. 시그니처
|
||||||
|
|
||||||
|
```java
|
||||||
|
private ProbeResult addLocalSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 책임 (단일 책임, 1줄)
|
||||||
|
|
||||||
|
ORDS 응답에 trace가 없을 때 DB predicate와 보호 객체 컬럼으로 확인용 effective SQL을 만든다.
|
||||||
|
|
||||||
|
## 3. 입력
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||||
|
|----------|------|-----------|------|
|
||||||
|
| `result` | `ProbeResult` | non-null | 이미 파싱된 성공/0행 결과 |
|
||||||
|
| `bearerToken` | `String` | 원문은 메모리에서만 사용 | DB context setter에 전달할 토큰 |
|
||||||
|
| `object` | `ProtectedObject` | 활성·식별자 검증 완료 | SQL FROM 대상 |
|
||||||
|
|
||||||
|
## 4. 출력
|
||||||
|
|
||||||
|
- **반환**: predicate와 effective SQL이 부착된 새 `ProbeResult`; 조회할 수 없으면 원 결과.
|
||||||
|
- **부수효과**: predicate 조회는 `findVpdPredicate`를 통한 DB I/O다. 결과 rows/status/evidence는 변경하지 않는다.
|
||||||
|
|
||||||
|
## 5. 동작 / 알고리즘
|
||||||
|
|
||||||
|
1. bearer/object로 `findVpdPredicate`를 호출한다.
|
||||||
|
2. predicate가 없으면 원 결과를 반환한다.
|
||||||
|
3. 보호 객체의 등록 컬럼을 `o.COLUMN` 목록으로 만든다.
|
||||||
|
4. `SELECT columns FROM owner.object o WHERE (predicate) AND ROWNUM <= ...` 문자열을 만든다.
|
||||||
|
5. `ProbeResult.withSqlTrace`로 불변 결과를 반환한다.
|
||||||
|
|
||||||
|
## 6. 에러 & 실패 모드
|
||||||
|
|
||||||
|
| 조건 | 처리 | 반환/예외 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| context/predicate DB 오류 | `findVpdPredicate`가 null 반환 | 원 결과 |
|
||||||
|
| 컬럼 catalog 오류 | runtime exception을 경계에서 흡수 | 원 결과 |
|
||||||
|
| 컬럼 목록 empty | trace 생성 생략 | 원 결과 |
|
||||||
|
| vector object | 호출부에서 이 함수 실행을 생략 | 전용 Handler trace만 사용 |
|
||||||
|
|
||||||
|
## 7. 엣지케이스
|
||||||
|
|
||||||
|
- 0행 결과도 호출 가능해야 한다.
|
||||||
|
- predicate의 `SYS_CONTEXT` 표현은 실제 값으로 치환하지 않는다.
|
||||||
|
- 컬럼명은 보호 객체 catalog에서 가져오며 임의 입력을 SQL에 추가하지 않는다.
|
||||||
|
- token은 반환 문자열·로그에 포함하지 않는다.
|
||||||
|
|
||||||
|
## 8. 복잡도 / 성능
|
||||||
|
|
||||||
|
- DB predicate 조회 1회와 컬럼 cache 조회 1회. 컬럼 수를 `n`이라 하면 SQL 문자열 조합은 `O(n)`이다.
|
||||||
|
- ORDS trace가 없는 경우에만 추가 DB 연결 작업을 수행한다.
|
||||||
|
|
||||||
|
## 9. 의존성
|
||||||
|
|
||||||
|
- `findVpdPredicate`, `ProtectedObjectService.findColumns`, `ProbeResult.withSqlTrace`.
|
||||||
|
- `ADMIN.CB_AGENT_DOC_VPD_FILTER`와 보호 객체 catalog.
|
||||||
|
|
||||||
|
## 10. 테스트 케이스
|
||||||
|
|
||||||
|
- [x] trace 부착 후 rows/status/rowCount 보존.
|
||||||
|
- [ ] predicate 조회 실패 시 원 결과 유지.
|
||||||
|
- [ ] empty columns 시 trace 생략.
|
||||||
|
- [ ] vector object는 호출부에서 생략.
|
||||||
|
|
||||||
|
## 11. 추적성
|
||||||
|
|
||||||
|
- 인수조건: #567의 “유효 토큰 조회 결과에 predicate/effective SQL 표시”.
|
||||||
|
- 관련 ADR: 없음.
|
||||||
73
docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md
Normal file
73
docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 함수 설계서: `findVpdPredicate` (#567)
|
||||||
|
|
||||||
|
> **부모 설계서**: ./README.md · **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke
|
||||||
|
|
||||||
|
## 1. 시그니처
|
||||||
|
|
||||||
|
```java
|
||||||
|
private String findVpdPredicate(String bearerToken, ProtectedObject object)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 책임 (단일 책임, 1줄)
|
||||||
|
|
||||||
|
하나의 pooled DB connection에서 Bearer 기반 context를 설정하고 동일 VPD 함수의 predicate를 조회한 뒤 context를 제거한다.
|
||||||
|
|
||||||
|
## 3. 입력
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||||
|
|----------|------|-----------|------|
|
||||||
|
| `bearerToken` | `String` | 상위 token 검증 통과 | `set_user_by_bearer` 입력 |
|
||||||
|
| `object` | `ProtectedObject` | 활성 object | predicate의 schema/object 입력 |
|
||||||
|
|
||||||
|
## 4. 출력
|
||||||
|
|
||||||
|
- **반환**: `ADMIN.CB_AGENT_DOC_VPD_FILTER(owner, object)`의 predicate 문자열 또는 조회 불가 시 null.
|
||||||
|
- **부수효과**: 같은 connection의 secure application context를 잠시 설정하고 반드시 clear한다. token 원문은 DB 함수 호출 이외의 출력으로 내보내지 않는다.
|
||||||
|
|
||||||
|
## 5. 동작 / 알고리즘
|
||||||
|
|
||||||
|
1. `JdbcTemplate.execute(ConnectionCallback)`으로 connection을 확보한다.
|
||||||
|
2. `BEGIN admin.cb_agent_ctx_pkg.set_user_by_bearer(?); END;`를 실행한다.
|
||||||
|
3. `SELECT admin.cb_agent_doc_vpd_filter(?, ?) FROM dual`을 실행한다.
|
||||||
|
4. `finally`에서 `BEGIN admin.cb_agent_ctx_pkg.clear_user; END;`를 실행한다.
|
||||||
|
5. Spring JDBC/DB runtime 오류는 trace optional 경로에서 null로 변환한다.
|
||||||
|
|
||||||
|
## 6. 에러 & 실패 모드
|
||||||
|
|
||||||
|
| 조건 | 처리 | 반환/예외 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| DB 연결/권한 오류 | fallback 경계에서 처리 | null |
|
||||||
|
| Bearer 매핑 실패 | DB package 오류 | null, 원 ORDS 결과 유지 |
|
||||||
|
| predicate 함수 오류 | DB query 오류 | null, 원 ORDS 결과 유지 |
|
||||||
|
| cleanup 오류 | 별도 오류로 덮지 않음 | predicate 결과 또는 null |
|
||||||
|
|
||||||
|
## 7. 엣지케이스
|
||||||
|
|
||||||
|
- context setter가 실패해도 `finally` cleanup을 시도한다.
|
||||||
|
- connection pool에 context가 남지 않아야 한다.
|
||||||
|
- ORDS와 백오피스가 다른 DB/계정인 구성에서는 null이 정상 fallback 결과일 수 있다.
|
||||||
|
- `ADMIN` 이외 함수 owner는 이번 설계에서 지원하지 않는다.
|
||||||
|
|
||||||
|
## 8. 복잡도 / 성능
|
||||||
|
|
||||||
|
- trace가 없는 성공/0행 조회당 DB connection 1회, PL/SQL 1회, predicate SELECT 1회.
|
||||||
|
- 결과 행 수나 token 수에 비례하지 않는 `O(1)` DB 호출이며, Handler trace가 있으면 호출하지 않는다.
|
||||||
|
|
||||||
|
## 9. 의존성
|
||||||
|
|
||||||
|
- Spring `JdbcTemplate`/`ConnectionCallback`.
|
||||||
|
- `ADMIN.CB_AGENT_CTX_PKG`, `ADMIN.CB_AGENT_DOC_VPD_FILTER`.
|
||||||
|
- connection pool cleanup semantics.
|
||||||
|
|
||||||
|
## 10. 테스트 케이스
|
||||||
|
|
||||||
|
- [ ] context setter와 predicate query가 같은 connection에서 실행됨.
|
||||||
|
- [ ] query 성공 후 clear가 실행됨.
|
||||||
|
- [ ] setter/query 실패 후 clear가 시도됨.
|
||||||
|
- [ ] DB privilege 미설정 시 원 ORDS 결과가 유지됨.
|
||||||
|
|
||||||
|
## 11. 추적성
|
||||||
|
|
||||||
|
- 인수조건: #567의 “기존 Handler도 trace fallback 제공” 및 “trace 실패가 권한 결과를 실패로 만들지 않음”.
|
||||||
|
- 관련 ADR: 없음.
|
||||||
17
docs/reference/ords-vpd-sql-trace.md
Normal file
17
docs/reference/ords-vpd-sql-trace.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# ORDS/VPD SQL trace
|
||||||
|
|
||||||
|
권한 결과 확인(`/probe`)은 ORDS 응답의 optional `vpd_predicate`, `effective_sql` 필드를 “토큰 적용 후 SQL” 영역에 표시한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vpd_predicate": "(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))",
|
||||||
|
"effective_sql": "SELECT ... WHERE (...) AND ROWNUM <= ...",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 Handler가 trace 필드를 반환하지 않으면 백오피스가 같은 DB 연결에서 `set_user_by_bearer` → `CB_AGENT_DOC_VPD_FILTER` → `clear_user` 순서로 기본 VPD predicate를 조회한다. 이 보조 조회가 실패해도 권한 결과 자체는 실패 처리하지 않는다.
|
||||||
|
|
||||||
|
기존 설치에 trace 권한을 추가하려면 ADMIN으로 `sql/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다.
|
||||||
|
|
||||||
|
표시되는 SQL은 Oracle optimizer 실행계획이 아니라, 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다.
|
||||||
@@ -477,6 +477,7 @@ GRANT CREATE SESSION TO cb_ords;
|
|||||||
GRANT CREATE PROCEDURE TO cb_ords;
|
GRANT CREATE PROCEDURE TO cb_ords;
|
||||||
GRANT SELECT ON cb_v_search_documents TO cb_ords;
|
GRANT SELECT ON cb_v_search_documents TO cb_ords;
|
||||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||||
|
GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords;
|
||||||
GRANT EXECUTE ON cb_agent_can_read_column TO cb_ords;
|
GRANT EXECUTE ON cb_agent_can_read_column TO cb_ords;
|
||||||
|
|
||||||
PROMPT === Local VPD setup complete ===
|
PROMPT === Local VPD setup complete ===
|
||||||
|
|||||||
@@ -90,9 +90,24 @@ BEGIN
|
|||||||
p_source => q'!
|
p_source => q'!
|
||||||
DECLARE
|
DECLARE
|
||||||
v_rows SYS_REFCURSOR;
|
v_rows SYS_REFCURSOR;
|
||||||
|
v_vpd_predicate 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);
|
||||||
|
|
||||||
|
-- The predicate below is the value returned by the same DBMS_RLS policy
|
||||||
|
-- function that protects ADMIN.CB_V_SEARCH_DOCUMENTS.
|
||||||
|
BEGIN
|
||||||
|
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS');
|
||||||
|
v_effective_sql := 'SELECT d.doc_id, d.title, d.owner_emp_no, d.dept_code, d.contents '
|
||||||
|
|| 'FROM admin.cb_v_search_documents d WHERE (' || v_vpd_predicate || ') '
|
||||||
|
|| 'AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
v_vpd_predicate := NULL;
|
||||||
|
v_effective_sql := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
OPEN v_rows FOR
|
OPEN v_rows FOR
|
||||||
SELECT d.doc_id,
|
SELECT d.doc_id,
|
||||||
d.title,
|
d.title,
|
||||||
@@ -108,6 +123,10 @@ BEGIN
|
|||||||
OWA_UTIL.HTTP_HEADER_CLOSE;
|
OWA_UTIL.HTTP_HEADER_CLOSE;
|
||||||
|
|
||||||
APEX_JSON.OPEN_OBJECT;
|
APEX_JSON.OPEN_OBJECT;
|
||||||
|
IF v_effective_sql IS NOT NULL THEN
|
||||||
|
APEX_JSON.WRITE('vpd_predicate', v_vpd_predicate);
|
||||||
|
APEX_JSON.WRITE('effective_sql', v_effective_sql);
|
||||||
|
END IF;
|
||||||
APEX_JSON.WRITE('items', v_rows);
|
APEX_JSON.WRITE('items', v_rows);
|
||||||
APEX_JSON.CLOSE_OBJECT;
|
APEX_JSON.CLOSE_OBJECT;
|
||||||
|
|
||||||
|
|||||||
@@ -200,4 +200,9 @@ END;
|
|||||||
|
|
||||||
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
|
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
|
||||||
|
|
||||||
|
-- ORDS SQL tracing calls the same policy function after set_vpd_context so
|
||||||
|
-- the response can expose the token-specific VPD predicate. This is
|
||||||
|
-- diagnostic metadata only; the SELECT remains protected by DBMS_RLS.
|
||||||
|
GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords;
|
||||||
|
|
||||||
PROMPT === Dynamic whitelist VPD filter ready ===
|
PROMPT === Dynamic whitelist VPD filter ready ===
|
||||||
|
|||||||
@@ -50,9 +50,24 @@ DECLARE
|
|||||||
v_rows SYS_REFCURSOR;
|
v_rows SYS_REFCURSOR;
|
||||||
v_body_text CLOB;
|
v_body_text CLOB;
|
||||||
v_embedding_text VARCHAR2(32767);
|
v_embedding_text VARCHAR2(32767);
|
||||||
|
v_vpd_predicate 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);
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS');
|
||||||
|
v_effective_sql := 'SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score '
|
||||||
|
|| 'FROM (SELECT ... FROM admin.cb_vector_search_documents d '
|
||||||
|
|| 'WHERE d.embedding IS NOT NULL /* VPD: ' || v_vpd_predicate || ' */ '
|
||||||
|
|| 'ORDER BY score) ranked_chunks '
|
||||||
|
|| 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
v_vpd_predicate := NULL;
|
||||||
|
v_effective_sql := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
-- ORDS exposes :body_text as a stream bind. Read it exactly once; referring
|
-- ORDS exposes :body_text as a stream bind. Read it exactly once; referring
|
||||||
-- to the stream bind twice causes ORA-17270 (Duplicate stream parameter).
|
-- to the stream bind twice causes ORA-17270 (Duplicate stream parameter).
|
||||||
v_body_text := :body_text;
|
v_body_text := :body_text;
|
||||||
@@ -108,6 +123,10 @@ BEGIN
|
|||||||
OWA_UTIL.HTTP_HEADER_CLOSE;
|
OWA_UTIL.HTTP_HEADER_CLOSE;
|
||||||
|
|
||||||
APEX_JSON.OPEN_OBJECT;
|
APEX_JSON.OPEN_OBJECT;
|
||||||
|
IF v_effective_sql IS NOT NULL THEN
|
||||||
|
APEX_JSON.WRITE('vpd_predicate', v_vpd_predicate);
|
||||||
|
APEX_JSON.WRITE('effective_sql', v_effective_sql);
|
||||||
|
END IF;
|
||||||
APEX_JSON.WRITE('items', v_rows);
|
APEX_JSON.WRITE('items', v_rows);
|
||||||
APEX_JSON.CLOSE_OBJECT;
|
APEX_JSON.CLOSE_OBJECT;
|
||||||
|
|
||||||
|
|||||||
21
sql/adb/33_agent_ords_sql_trace_grant.sql
Normal file
21
sql/adb/33_agent_ords_sql_trace_grant.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 33_agent_ords_sql_trace_grant.sql
|
||||||
|
-- Enable the optional VPD predicate trace for an existing ORDS install.
|
||||||
|
--
|
||||||
|
-- Run as ADMIN after 26_agent_ords_security_dynamic_vpd_filter.sql.
|
||||||
|
-- Then run 22_agent_ords_security_ords_handler_setup.sql as CB_ORDS, or
|
||||||
|
-- save the trace-enabled Handler source from the backoffice UI.
|
||||||
|
--
|
||||||
|
-- The grant exposes only the existing predicate function to the ORDS
|
||||||
|
-- runtime. It does not weaken DBMS_RLS enforcement or expose bearer values.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
|
SET ECHO ON
|
||||||
|
SET FEEDBACK ON
|
||||||
|
|
||||||
|
PROMPT === Enabling ORDS VPD predicate trace ===
|
||||||
|
GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords;
|
||||||
|
|
||||||
|
PROMPT === ORDS VPD predicate trace grant ready ===
|
||||||
|
PROMPT Next: run 22_agent_ords_security_ords_handler_setup.sql as CB_ORDS
|
||||||
|
EXIT;
|
||||||
@@ -4,6 +4,22 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public record ProbeResult(
|
public record ProbeResult(
|
||||||
|
ProbeStatus status,
|
||||||
|
List<String> columns,
|
||||||
|
List<Map<String, Object>> rows,
|
||||||
|
int rowCount,
|
||||||
|
List<String> maskedColumns,
|
||||||
|
String errorCode,
|
||||||
|
String errorMessage,
|
||||||
|
String requestHeaders,
|
||||||
|
String requestPayload,
|
||||||
|
String responseHeaders,
|
||||||
|
String responseBody,
|
||||||
|
String vpdPredicate,
|
||||||
|
String effectiveSql
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ProbeResult(
|
||||||
ProbeStatus status,
|
ProbeStatus status,
|
||||||
List<String> columns,
|
List<String> columns,
|
||||||
List<Map<String, Object>> rows,
|
List<Map<String, Object>> rows,
|
||||||
@@ -16,6 +32,22 @@ public record ProbeResult(
|
|||||||
String responseHeaders,
|
String responseHeaders,
|
||||||
String responseBody
|
String responseBody
|
||||||
) {
|
) {
|
||||||
|
this(
|
||||||
|
status,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowCount,
|
||||||
|
maskedColumns,
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
requestHeaders,
|
||||||
|
requestPayload,
|
||||||
|
responseHeaders,
|
||||||
|
responseBody,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static ProbeResult blocked(ProbeStatus status, String errorCode, String errorMessage) {
|
public static ProbeResult blocked(ProbeStatus status, String errorCode, String errorMessage) {
|
||||||
return blocked(status, errorCode, errorMessage, null, null, null, null);
|
return blocked(status, errorCode, errorMessage, null, null, null, null);
|
||||||
@@ -49,6 +81,28 @@ public record ProbeResult(
|
|||||||
return status == ProbeStatus.SUCCESS || status == ProbeStatus.VPD_DENY_EMPTY_RESULT;
|
return status == ProbeStatus.SUCCESS || status == ProbeStatus.VPD_DENY_EMPTY_RESULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasSqlTrace() {
|
||||||
|
return effectiveSql != null && !effectiveSql.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProbeResult withSqlTrace(String predicate, String sql) {
|
||||||
|
return new ProbeResult(
|
||||||
|
status,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowCount,
|
||||||
|
maskedColumns,
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
requestHeaders,
|
||||||
|
requestPayload,
|
||||||
|
responseHeaders,
|
||||||
|
responseBody,
|
||||||
|
predicate,
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public String title() {
|
public String title() {
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";
|
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";
|
||||||
|
|||||||
@@ -249,12 +249,30 @@ public class OrdsMetadataService {
|
|||||||
.map(column -> "o." + column)
|
.map(column -> "o." + column)
|
||||||
.reduce((left, right) -> left + ",\n " + right)
|
.reduce((left, right) -> left + ",\n " + right)
|
||||||
.orElseThrow();
|
.orElseThrow();
|
||||||
|
String traceSelectColumns = columns.stream()
|
||||||
|
.map(column -> "o." + column)
|
||||||
|
.reduce((left, right) -> left + ", " + right)
|
||||||
|
.orElseThrow();
|
||||||
return """
|
return """
|
||||||
DECLARE
|
DECLARE
|
||||||
v_rows SYS_REFCURSOR;
|
v_rows SYS_REFCURSOR;
|
||||||
|
v_vpd_predicate 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);
|
||||||
|
|
||||||
|
-- This is the same predicate function invoked by DBMS_RLS for the
|
||||||
|
-- SELECT below. It is returned only as diagnostic metadata.
|
||||||
|
BEGIN
|
||||||
|
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('%s', '%s');
|
||||||
|
v_effective_sql := 'SELECT %s FROM %s.%s o WHERE (' || v_vpd_predicate
|
||||||
|
|| ') AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
v_vpd_predicate := NULL;
|
||||||
|
v_effective_sql := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
OPEN v_rows FOR
|
OPEN v_rows FOR
|
||||||
SELECT %s
|
SELECT %s
|
||||||
FROM %s.%s o
|
FROM %s.%s o
|
||||||
@@ -266,6 +284,10 @@ public class OrdsMetadataService {
|
|||||||
OWA_UTIL.HTTP_HEADER_CLOSE;
|
OWA_UTIL.HTTP_HEADER_CLOSE;
|
||||||
|
|
||||||
APEX_JSON.OPEN_OBJECT;
|
APEX_JSON.OPEN_OBJECT;
|
||||||
|
IF v_effective_sql IS NOT NULL THEN
|
||||||
|
APEX_JSON.WRITE('vpd_predicate', v_vpd_predicate);
|
||||||
|
APEX_JSON.WRITE('effective_sql', v_effective_sql);
|
||||||
|
END IF;
|
||||||
APEX_JSON.WRITE('items', v_rows);
|
APEX_JSON.WRITE('items', v_rows);
|
||||||
APEX_JSON.CLOSE_OBJECT;
|
APEX_JSON.CLOSE_OBJECT;
|
||||||
|
|
||||||
@@ -281,7 +303,15 @@ public class OrdsMetadataService {
|
|||||||
APEX_JSON.WRITE('error', SQLERRM);
|
APEX_JSON.WRITE('error', SQLERRM);
|
||||||
APEX_JSON.CLOSE_OBJECT;
|
APEX_JSON.CLOSE_OBJECT;
|
||||||
END;
|
END;
|
||||||
""".formatted(selectColumns, object.owner(), object.objectName());
|
""".formatted(
|
||||||
|
object.owner(),
|
||||||
|
object.objectName(),
|
||||||
|
traceSelectColumns,
|
||||||
|
object.owner(),
|
||||||
|
object.objectName(),
|
||||||
|
selectColumns,
|
||||||
|
object.owner(),
|
||||||
|
object.objectName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void rejectGenericVectorHandler(ProtectedObject object) {
|
private void rejectGenericVectorHandler(ProtectedObject object) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@@ -20,11 +23,15 @@ 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 org.springframework.jdbc.core.ConnectionCallback;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
import org.springframework.web.client.HttpStatusCodeException;
|
||||||
import org.springframework.web.client.ResourceAccessException;
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
@@ -36,6 +43,7 @@ public class OrdsProbeService {
|
|||||||
|
|
||||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
|
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
|
||||||
};
|
};
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OrdsProbeService.class);
|
||||||
|
|
||||||
private final BearerTokenService tokenService;
|
private final BearerTokenService tokenService;
|
||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
@@ -44,6 +52,7 @@ public class OrdsProbeService {
|
|||||||
private final RestTemplate ordsRestTemplate;
|
private final RestTemplate ordsRestTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final SettingService settingService;
|
private final SettingService settingService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public OrdsProbeService(
|
public OrdsProbeService(
|
||||||
@@ -54,6 +63,7 @@ public class OrdsProbeService {
|
|||||||
RestTemplate ordsRestTemplate,
|
RestTemplate ordsRestTemplate,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
SettingService settingService,
|
SettingService settingService,
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
Clock clock
|
Clock clock
|
||||||
) {
|
) {
|
||||||
this.tokenService = tokenService;
|
this.tokenService = tokenService;
|
||||||
@@ -63,6 +73,7 @@ public class OrdsProbeService {
|
|||||||
this.ordsRestTemplate = ordsRestTemplate;
|
this.ordsRestTemplate = ordsRestTemplate;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.settingService = settingService;
|
this.settingService = settingService;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +142,9 @@ public class OrdsProbeService {
|
|||||||
prettyHeaders(response.getHeaders()),
|
prettyHeaders(response.getHeaders()),
|
||||||
prettyJson(response.getBody())
|
prettyJson(response.getBody())
|
||||||
);
|
);
|
||||||
|
if (!result.hasSqlTrace() && !isVectorSearchObject(object)) {
|
||||||
|
result = addLocalSqlTrace(result, command.bearerToken(), object);
|
||||||
|
}
|
||||||
return auditAndReturn(command, result);
|
return auditAndReturn(command, result);
|
||||||
} catch (HttpStatusCodeException e) {
|
} catch (HttpStatusCodeException e) {
|
||||||
ProbeStatus status = errorClassifier.classify(e.getStatusCode(), e.getResponseBodyAsString());
|
ProbeStatus status = errorClassifier.classify(e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
@@ -205,10 +219,93 @@ public class OrdsProbeService {
|
|||||||
requestHeaders,
|
requestHeaders,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
responseHeaders,
|
responseHeaders,
|
||||||
responseBody
|
responseBody,
|
||||||
|
traceValue(root, "vpd_predicate"),
|
||||||
|
traceValue(root, "effective_sql")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String traceValue(JsonNode root, String fieldName) {
|
||||||
|
JsonNode value = root == null ? null : root.get(fieldName);
|
||||||
|
if (value == null || value.isNull() || !value.isValueNode()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = value.asText();
|
||||||
|
return text == null || text.isBlank() ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProbeResult addLocalSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object) {
|
||||||
|
String predicate = findVpdPredicate(bearerToken, object);
|
||||||
|
if (predicate == null || predicate.isBlank()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
List<String> columns;
|
||||||
|
try {
|
||||||
|
columns = protectedObjectService.findColumns(object.objectId()).stream()
|
||||||
|
.map(column -> "o." + column.columnName())
|
||||||
|
.toList();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (columns.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String effectiveSql = "SELECT " + String.join(", ", columns)
|
||||||
|
+ " FROM " + object.owner() + "." + object.objectName() + " o"
|
||||||
|
+ " WHERE (" + predicate + ")"
|
||||||
|
+ " AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)";
|
||||||
|
return result.withSqlTrace(predicate, effectiveSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findVpdPredicate(String bearerToken, ProtectedObject object) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.execute((ConnectionCallback<String>) connection -> {
|
||||||
|
try {
|
||||||
|
executeContextSetter(connection, bearerToken);
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT admin.cb_agent_doc_vpd_filter(?, ?) FROM dual")) {
|
||||||
|
statement.setString(1, object.owner());
|
||||||
|
statement.setString(2, object.objectName());
|
||||||
|
try (ResultSet result = statement.executeQuery()) {
|
||||||
|
return result.next() ? result.getString(1) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearContext(connection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
// The backoffice may use a different DB account or a database without
|
||||||
|
// the optional trace privilege. The ORDS response remains authoritative
|
||||||
|
// when the Handler itself returned trace fields.
|
||||||
|
log.debug("VPD SQL trace unavailable for {}.{}: {}",
|
||||||
|
object.owner(), object.objectName(), exception.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeContextSetter(Connection connection, String bearerToken) throws java.sql.SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"BEGIN admin.cb_agent_ctx_pkg.set_user_by_bearer(?); END;")) {
|
||||||
|
statement.setString(1, bearerToken);
|
||||||
|
statement.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearContext(Connection connection) {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"BEGIN admin.cb_agent_ctx_pkg.clear_user; END;")) {
|
||||||
|
statement.execute();
|
||||||
|
} catch (java.sql.SQLException exception) {
|
||||||
|
// Do not replace a successful ORDS result with a diagnostic cleanup error.
|
||||||
|
log.debug("VPD context cleanup failed: {}", exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isVectorSearchObject(ProtectedObject object) {
|
||||||
|
return "CB_VECTOR_SEARCH_DOCUMENTS".equalsIgnoreCase(object.objectName());
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> findMaskedColumns(long objectId, List<Map<String, Object>> rows) {
|
private List<String> findMaskedColumns(long objectId, List<Map<String, Object>> rows) {
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
@@ -70,6 +70,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="result-section sql-trace-section" th:if="${result.hasSqlTrace()}">
|
||||||
|
<div class="section-heading compact-heading">
|
||||||
|
<div>
|
||||||
|
<h3>토큰 적용 후 SQL</h3>
|
||||||
|
<p class="section-subtitle">ORDS Handler가 반환한 VPD predicate 또는 같은 토큰 컨텍스트를 재현해 조회한 predicate를 기본 조회문에 합친 형태입니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-light">DBMS_RLS predicate</span>
|
||||||
|
</div>
|
||||||
|
<div class="probe-exchange-grid mt-3">
|
||||||
|
<section class="probe-exchange" data-sql-trace-field="vpd_predicate">
|
||||||
|
<h3>VPD가 추가한 WHERE 조건</h3>
|
||||||
|
<pre th:text="${result.vpdPredicate()}">(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))</pre>
|
||||||
|
</section>
|
||||||
|
<section class="probe-exchange" data-sql-trace-field="effective_sql">
|
||||||
|
<h3>권한 적용 SQL</h3>
|
||||||
|
<pre th:text="${result.effectiveSql()}">SELECT ... WHERE (...) AND ROWNUM <= ...</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint mt-2 mb-0">
|
||||||
|
Oracle 내부 optimizer의 실행계획이나 bind 값 치환 결과가 아니라, 이 토큰 컨텍스트에서 VPD 정책 함수가 실제로 반환한 행 조건을 표시합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="alert alert-light mb-0" th:if="${result.successLike() and !result.hasSqlTrace()}">
|
||||||
|
이 Handler는 SQL trace 정보를 반환하지 않았고 백오피스 DB에서도 기본 VPD predicate를 조회하지 못했습니다. <a href="/ords-handlers">ORDS 핸들러</a>에서 trace가 포함된 소스를 적용하고, <code>CB_AGENT_DOC_VPD_FILTER</code> 실행 권한을 확인하세요.
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="next-action-card">
|
<section class="next-action-card">
|
||||||
<h3>다음에 할 일</h3>
|
<h3>다음에 할 일</h3>
|
||||||
<p th:text="${result.nextAction()}">다음 행동</p>
|
<p th:text="${result.nextAction()}">다음 행동</p>
|
||||||
|
|||||||
@@ -9,10 +9,14 @@
|
|||||||
<p class="context-summary">토큰을 DB 권한 컨텍스트로 바꾸는 Handler를 확인·수정합니다.</p>
|
<p class="context-summary">토큰을 DB 권한 컨텍스트로 바꾸는 Handler를 확인·수정합니다.</p>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>Handler 처리 흐름 설명 보기</summary>
|
<summary>Handler 처리 흐름 설명 보기</summary>
|
||||||
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD가 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다.</p>
|
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD가 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다. 실행 결과의 “토큰 적용 후 SQL”은 권한 결과 확인 메뉴에서 확인할 수 있습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
권한별로 VPD가 붙인 실제 행 조건을 확인하려면 <a href="/probe">권한 결과 확인</a>에서 이 Handler의 보호 객체를 실행하세요. SQL trace를 반환하도록 갱신된 Handler는 같은 ORDS 세션의 <code>CB_AGENT_DOC_VPD_FILTER</code> predicate를 함께 표시합니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||||
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -97,4 +97,29 @@ class ProbeResultTest {
|
|||||||
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
|
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
|
||||||
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
|
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exposesVpdSqlTraceWithoutChangingTheRows() {
|
||||||
|
ProbeResult result = new ProbeResult(
|
||||||
|
ProbeStatus.SUCCESS,
|
||||||
|
List.of("DEPT_CODE"),
|
||||||
|
List.of(Map.of("DEPT_CODE", "HR")),
|
||||||
|
1,
|
||||||
|
List.of(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
).withSqlTrace(
|
||||||
|
"(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))",
|
||||||
|
"SELECT * FROM ADMIN.DOCUMENTS WHERE (DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(result.hasSqlTrace()).isTrue();
|
||||||
|
assertThat(result.vpdPredicate()).contains("DEPT_CODE");
|
||||||
|
assertThat(result.effectiveSql()).contains("ADMIN.DOCUMENTS");
|
||||||
|
assertThat(result.rowCount()).isEqualTo(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.OrdsMetadataMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
class OrdsMetadataServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatedObjectHandlerIncludesTokenSpecificVpdSqlTrace() {
|
||||||
|
OrdsMetadataMapper mapper = mock(OrdsMetadataMapper.class);
|
||||||
|
ProtectedObjectService protectedObjectService = mock(ProtectedObjectService.class);
|
||||||
|
DataSource dataSource = mock(DataSource.class);
|
||||||
|
ProtectedObject object = new ProtectedObject(
|
||||||
|
7L,
|
||||||
|
"ADMIN",
|
||||||
|
"CB_V_SEARCH_DOCUMENTS",
|
||||||
|
"cb-ords/cb-object-query/admin/cb_v_search_documents",
|
||||||
|
"Y"
|
||||||
|
);
|
||||||
|
when(protectedObjectService.assertEnabled(7L)).thenReturn(object);
|
||||||
|
when(protectedObjectService.findColumns(7L)).thenReturn(List.of(
|
||||||
|
new ProtectedColumn(1L, 7L, "DOC_ID", "N", null, "PUBLIC", "NONE"),
|
||||||
|
new ProtectedColumn(2L, 7L, "DEPT_CODE", "N", null, "INTERNAL", "NONE")
|
||||||
|
));
|
||||||
|
|
||||||
|
OrdsMetadataService service = new OrdsMetadataService(
|
||||||
|
mapper,
|
||||||
|
new JdbcTemplate(dataSource),
|
||||||
|
protectedObjectService,
|
||||||
|
dataSource,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
String source = service.objectQueryHandlerSource(7L);
|
||||||
|
|
||||||
|
assertThat(source)
|
||||||
|
.contains("cb_ords_handler_pkg.set_vpd_context(:auth_header)")
|
||||||
|
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
|
||||||
|
.contains("v_vpd_predicate")
|
||||||
|
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)")
|
||||||
|
.contains("SELECT o.DOC_ID, o.DEPT_CODE FROM ADMIN.CB_V_SEARCH_DOCUMENTS o");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ class GuidedFlowTemplateTest {
|
|||||||
.doesNotContain("name=\"tokenKeyId\"");
|
.doesNotContain("name=\"tokenKeyId\"");
|
||||||
assertThat(result)
|
assertThat(result)
|
||||||
.contains("적용된 사용자와 권한")
|
.contains("적용된 사용자와 권한")
|
||||||
|
.contains("토큰 적용 후 SQL")
|
||||||
|
.contains("vpd_predicate")
|
||||||
.contains("다음에 할 일")
|
.contains("다음에 할 일")
|
||||||
.contains("<details")
|
.contains("<details")
|
||||||
.contains("기술 상세");
|
.contains("기술 상세");
|
||||||
@@ -138,6 +140,18 @@ class GuidedFlowTemplateTest {
|
|||||||
.doesNotContain("JSON_QUERY(\n :body_text")
|
.doesNotContain("JSON_QUERY(\n :body_text")
|
||||||
.doesNotContain("JSON_VALUE(\n :body_text");
|
.doesNotContain("JSON_VALUE(\n :body_text");
|
||||||
assertThat(sql.split("v_body_text := :body_text", -1)).hasSize(2);
|
assertThat(sql.split("v_body_text := :body_text", -1)).hasSize(2);
|
||||||
|
assertThat(sql).contains("vpd_predicate").contains("effective_sql");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingOrdsSetupCanReturnTheVpdPredicateTrace() throws IOException {
|
||||||
|
String setup = Files.readString(Path.of("sql/adb/22_agent_ords_security_ords_handler_setup.sql"));
|
||||||
|
String grant = Files.readString(Path.of("sql/adb/33_agent_ords_sql_trace_grant.sql"));
|
||||||
|
|
||||||
|
assertThat(setup)
|
||||||
|
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
|
||||||
|
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)");
|
||||||
|
assertThat(grant).contains("GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String template(String relativePath) throws IOException {
|
private String template(String relativePath) throws IOException {
|
||||||
|
|||||||
Reference in New Issue
Block a user