Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -26,11 +26,23 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}"
# --- (2b) Spring Boot 백오피스 --- # --- (2b) Spring Boot 백오피스 ---
export BACKOFFICE_ADMIN_USER="admin" export BACKOFFICE_ADMIN_USER="admin"
export BACKOFFICE_ADMIN_PASSWORD="admin" export BACKOFFICE_ADMIN_PASSWORD="admin"
# 운영에서는 최초 한 번 생성한 {bcrypt} 해시를 .env에 보관합니다. 이를 사용하면
# 지속 로그인 쿠키가 앱 재기동 후에도 유효합니다. 원문 비밀번호 값은 Git에 넣지 마세요.
export BACKOFFICE_ADMIN_PASSWORD_HASH=""
# 읽기 전용 조회 계정. 활성화하면 ROLE_VIEWER로 로그인 가능하며 POST/PUT/PATCH/DELETE는 차단됩니다.
export BACKOFFICE_GUEST_ENABLED="false"
export BACKOFFICE_GUEST_USER="guest"
export BACKOFFICE_GUEST_PASSWORD=""
export BACKOFFICE_GUEST_PASSWORD_HASH=""
# 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다. # 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다.
export BACKOFFICE_BIND_ADDRESS="0.0.0.0" export BACKOFFICE_BIND_ADDRESS="0.0.0.0"
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none" export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none"
export BACKOFFICE_REQUIRE_HTTPS="false" export BACKOFFICE_REQUIRE_HTTPS="false"
export BACKOFFICE_SESSION_COOKIE_SECURE="false" export BACKOFFICE_SESSION_COOKIE_SECURE="false"
# HTTPS에서만 사용. key는 배포 환경의 secret으로 32자 이상 임의값을 넣고 Git에 저장하지 않습니다.
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
export BACKOFFICE_REMEMBER_ME_KEY=""
export BACKOFFICE_REMEMBER_ME_DAYS="14"
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords" export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10" export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로 # ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
@@ -41,10 +53,18 @@ export BACKOFFICE_ORDS_DB_PASSWORD=""
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- # --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false" export BACKOFFICE_AI_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini
export BACKOFFICE_AI_API_KEY="" export BACKOFFICE_AI_API_KEY=""
export BACKOFFICE_AI_TIMEOUT_SECONDS="30" export BACKOFFICE_AI_TIMEOUT_SECONDS="30"
# provider=oci이면 API key를 환경 변수로 복제하지 않고 OCI config profile로 요청에
# 서명합니다. POC3_LLM_GPT55_OCI_*/OCI_GENAI_*가 있으면 아래 BACKOFFICE_* 값보다
# fallback으로 사용됩니다.
export BACKOFFICE_AI_OCI_CONFIG_FILE=""
export BACKOFFICE_AI_OCI_PROFILE="DEFAULT"
export BACKOFFICE_AI_OCI_REGION=""
export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
# 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다. # 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다.
export VPDTEST1_API_KEY="" export VPDTEST1_API_KEY=""

File diff suppressed because it is too large Load Diff

63
docs/README.md Normal file
View File

@@ -0,0 +1,63 @@
# vpd-permission-poc 문서 아키텍처 (Documentation Map)
이 프로젝트의 문서는 **Diátaxis** 프레임워크 + **ADR** + **설계서(Design Spec)**
결합한 구조를 따른다. 모든 페르소나는 문서를 만들거나 참조할 때 이 지도를 기준으로 한다.
## 디렉토리 구조
```
docs/
README.md ← (이 파일) 문서 지도 · 인덱스
design/ ← 설계서: 구현 "전"에 작성하는 필수 산출물 (Design-First 게이트)
_TEMPLATE.md 기능 설계서 템플릿
_FN_TEMPLATE.md 함수별 설계서 템플릿
<issue-id>-<slug>/ 기능 1개(이슈 1개)당 폴더
README.md 기능 설계서 (전체 설계 + 함수 명세 표)
fn-<name>.md 복잡한 함수만 개별 함수 설계서
adr/ ← Architecture Decision Records: 가로지르는 결정 기록
_TEMPLATE.md
NNNN-<title>.md
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
```
## Diátaxis 사분면 매핑
| 사분면 | 목적 | 여기서 위치 |
|--------|------|-------------|
| **Tutorials** (학습) | 처음 사용자가 따라하기 | `guides/` (getting-started) |
| **How-to** (문제해결) | 특정 작업 수행 | `guides/` |
| **Reference** (정보) | 정확한 사양 조회 | `reference/` |
| **Explanation** (이해) | 왜 이렇게 설계했나 | `design/`, `adr/` |
## 문서 종류와 책임
| 문서 | 작성 페르소나 | 시점 | 한 줄 |
|------|---------------|------|-------|
| 기능 설계서 `design/<id>/README.md` | **Architect** | 구현 **전** | 무엇을·어떻게 만들지의 청사진 |
| 함수 설계서 `design/<id>/fn-*.md` | **Architect** | 구현 **전** | 복잡 함수의 계약·알고리즘·테스트 |
| ADR `adr/NNNN-*.md` | **Architect** | 결정 시 | 되돌리기 어려운 선택과 근거 |
| 레퍼런스 `reference/*` | **Developer/Documenter** | 구현 **후** | 실제 코드 사양 |
| 가이드 `guides/*` | **Documenter** | 릴리스 시 | 사용/운영 방법 |
## 핵심 규칙 — Design-First (하드 게이트)
> **설계서 없이는 코드 없음.** 어떤 함수든 구현 전에 그 함수가 설계서로 덮여 있어야 한다
> (단순 함수: 기능 설계서의 함수 명세 표 / 복잡 함수: 개별 `fn-*.md`).
> Developer 는 설계서가 없으면 구현을 거부하고 Architect 단계로 반려한다.
> 자세한 기준은 `CLAUDE.md` §2 참조.
## 명명 · 추적성 규칙
- 설계서 폴더: `design/<issue-id>-<kebab-slug>/` (예: `design/45-trailing-stop/`).
- 함수 설계서: `fn-<function_name>.md` (예: `fn-calc_trailing_stop.md`).
- ADR: 4자리 일련번호 `adr/0001-<title>.md`, 번호 재사용 금지.
- 모든 설계서·ADR 상단에 **추적성 헤더**(Redmine 이슈, 관련 ADR, 구현 파일, 테스트)를 둔다.
- 코드 ↔ 설계서 양방향 링크: 설계서는 구현 파일 경로를, 코드 주석/문서는 설계서 경로를 가리킨다.
## 문서 수명주기
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
```

24
docs/adr/_TEMPLATE.md Normal file
View File

@@ -0,0 +1,24 @@
<!-- ADR 템플릿. 복사해서 adr/NNNN-<kebab-title>.md (4자리 일련번호). -->
# ADR-NNNN: <제목>
> **상태**: Proposed <!-- Proposed | Accepted | Superseded by ADR-XXXX -->
> **날짜**: <YYYY-MM-DD> · **결정자**: [AI] Architect · **관련 이슈**: #<id>
## 맥락 (Context)
무엇이 이 결정을 강제하는가. 배경·제약·요구.
## 결정 (Decision)
우리는 무엇을 하기로 했는가. (명확한 한 문단)
## 근거 (Rationale)
왜 이 선택인가. 핵심 트레이드오프.
## 결과 (Consequences)
- **긍정**: ...
- **부정 / 비용**: ...
- **후속 작업**: ...
## 검토한 대안 (Alternatives Considered)
- **<대안 A>** — 기각 사유: ...
- **<대안 B>** — 기각 사유: ...

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="620" viewBox="0 0 1120 620" role="img" aria-labelledby="title desc">
<title id="title">보안 인벤토리 확인 화면</title>
<desc id="desc">VPD, Redaction, DDS 권한 적용 현황을 확인하는 화면.</desc>
<rect width="1120" height="620" fill="#f4f6f8"/>
<rect x="34" y="30" width="1052" height="560" rx="10" fill="#1f2937"/>
<circle cx="68" cy="58" r="7" fill="#ef4444"/>
<circle cx="92" cy="58" r="7" fill="#f59e0b"/>
<circle cx="116" cy="58" r="7" fill="#22c55e"/>
<text x="150" y="64" fill="#d1d5db" font-family="Menlo, Consolas, monospace" font-size="18">ADMIN Inventory - Policy and Data Grant Matrix</text>
<text x="58" y="112" fill="#fde68a" font-family="Menlo, Consolas, monospace" font-size="20">=== VPD / Redaction 적용 객체 ===</text>
<text x="58" y="160" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">OBJECT_NAME POLICY ENABLE</text>
<line x1="58" y1="182" x2="1038" y2="182" stroke="#4b5563" stroke-width="2"/>
<text x="58" y="222" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">CB_V_SEARCH_DOCUMENTS CB_AGENT_DOC_POLICY YES</text>
<text x="58" y="262" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">CB_V_SEARCH_DOCUMENTS CB_CONTENTS_REDACT YES</text>
<text x="58" y="302" fill="#fde68a" font-family="Menlo, Consolas, monospace" font-size="20">=== DDS END USER -> DATA ROLE -> DATA GRANT ===</text>
<text x="58" y="342" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">cb_dds_hr CB_DDS_HR_ROLE dept_code='HR' EXCEPT CONTENTS</text>
<text x="58" y="382" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">cb_dds_fin CB_DDS_FIN_ROLE dept_code='FIN' EXCEPT CONTENTS</text>
<text x="58" y="422" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">cb_dds_all CB_DDS_ALL_ROLE 1=1 ALL COLUMNS</text>
<text x="58" y="462" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">cb_dds_none CONNECT_ONLY no DATA GRANT ORA-00942</text>
<text x="58" y="524" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">확인 SQL: DBA_POLICIES, REDACTION_POLICIES, DBA_DATA_GRANTS</text>
<text x="58" y="558" fill="#86efac" font-family="Menlo, Consolas, monospace" font-size="18">결과: 객체별 정책과 End User별 Grant를 한 번에 대조</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="620" viewBox="0 0 1120 620" role="img" aria-labelledby="title desc">
<title id="title">Bearer Key ORDS 요청 화면</title>
<desc id="desc">ORDS Bearer Key 조회, 사용자 매핑, 필터링된 검색 결과 요약 화면.</desc>
<rect width="1120" height="620" fill="#f4f6f8"/>
<rect x="34" y="30" width="1052" height="560" rx="10" fill="#172033"/>
<circle cx="68" cy="58" r="7" fill="#ef4444"/>
<circle cx="92" cy="58" r="7" fill="#f59e0b"/>
<circle cx="116" cy="58" r="7" fill="#22c55e"/>
<text x="150" y="64" fill="#d1d5db" font-family="Menlo, Consolas, monospace" font-size="18">ORDS - Header Bearer Key 사용자 매핑</text>
<text x="58" y="112" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== 수신 요청 ===</text>
<text x="58" y="154" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">POST /ords/cb-ords/cb-agent-security/vpd/documents</text>
<text x="58" y="188" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">Authorization: Bearer cb_hr_key</text>
<text x="58" y="246" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== ORDS 처리 로직: 필수값 확인 ===</text>
<text x="58" y="288" fill="#fca5a5" font-family="Menlo, Consolas, monospace" font-size="18">Authorization Header 필수. 없으면 ORA-20101 또는 401/403</text>
<text x="58" y="336" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== ORDS 처리 로직: Key 매핑 ===</text>
<text x="58" y="376" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">1. Key Hash 계산 -> SHA256: 4A91...7C20</text>
<text x="58" y="410" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">2. CB_AGENT_BEARER_KEY 조회 -> USER_ID=101, KEY_ID=1</text>
<text x="58" y="444" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">3. Context reset + set -> EMP_NO=E10234, DEPT_CODE=HR</text>
<text x="58" y="496" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== 보호 객체 조회 ===</text>
<text x="58" y="532" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">CB_V_SEARCH_DOCUMENTS 조회 -> HR 행 3건, CONTENTS=NULL</text>
<text x="58" y="566" fill="#86efac" font-family="Menlo, Consolas, monospace" font-size="18">결과: ORDS 처리 로직이 Header Key를 사용자로 매핑</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1240" height="700" viewBox="0 0 1240 700" role="img" aria-labelledby="title desc">
<title id="title">DDS Bearer Key 처리 검증</title>
<desc id="desc">ORDS Handler가 Bearer Key를 읽어도 DDS EndUserSecurityContext가 붙지 않으면 DATA GRANT가 Key 사용자를 인식하지 못하는 흐름.</desc>
<rect width="1240" height="700" fill="#f8fafc"/>
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto">
<path d="M 0 0 L 9 4.5 L 0 9 z" fill="#475569"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 35px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 20px; fill: #475569; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 21px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 17px; fill: #334155; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #0f172a; }
.mono-small { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #0f172a; }
.card { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2.2; }
.ok { fill: #ecfdf5; stroke: #059669; stroke-width: 2.2; }
.warn { fill: #fff7ed; stroke: #ea580c; stroke-width: 2.2; }
.bad { fill: #fef2f2; stroke: #dc2626; stroke-width: 2.2; }
.driver { fill: #eff6ff; stroke: #2563eb; stroke-width: 2.2; }
.arrow { stroke: #475569; stroke-width: 2.2; marker-end: url(#arrow); fill: none; }
.dash { stroke: #94a3b8; stroke-width: 2.2; stroke-dasharray: 8 7; marker-end: url(#arrow); fill: none; }
</style>
</defs>
<text x="42" y="56" class="h1">DDS Bearer Key 검증 결과</text>
<text x="42" y="90" class="sub">ORDS Handler의 Header 매핑과 DDS EndUserSecurityContext 전파는 별도 단계</text>
<rect x="42" y="126" width="320" height="122" rx="10" class="card"/>
<text x="68" y="166" class="title">1. Agent 요청</text>
<text x="68" y="204" class="mono">Authorization:</text>
<text x="68" y="230" class="mono">Bearer cb_hr_key</text>
<line x1="362" y1="187" x2="432" y2="187" class="arrow"/>
<rect x="448" y="126" width="344" height="122" rx="10" class="ok"/>
<text x="474" y="166" class="title">2. ORDS Handler</text>
<text x="474" y="204" class="text">Header 필수값 확인</text>
<text x="474" y="230" class="text">Key를 내부 사용자로 매핑</text>
<line x1="792" y1="187" x2="862" y2="187" class="arrow"/>
<rect x="878" y="126" width="320" height="122" rx="10" class="warn"/>
<text x="904" y="166" class="title">3. DB Session</text>
<text x="904" y="204" class="mono">SESSION_USER = CB_ORDS</text>
<text x="904" y="230" class="mono">DDS username = null</text>
<path d="M 1038 248 C 1038 302, 620 302, 620 346" class="arrow"/>
<rect x="448" y="346" width="344" height="142" rx="10" class="bad"/>
<text x="474" y="386" class="title">검증된 차단 결과</text>
<text x="474" y="424" class="mono-small">mapped_end_user=cb_dds_hr</text>
<text x="474" y="452" class="mono">dds_context_username=null</text>
<text x="474" y="480" class="mono">ORA-00942 / EXPECTED_BLOCKED</text>
<path d="M 448 417 C 344 417, 318 312, 250 260" class="dash"/>
<text x="82" y="310" class="text">Key 사용자명을 변수로</text>
<text x="82" y="335" class="text">보관하는 것만으로는</text>
<text x="82" y="360" class="text">DDS 사용자가 되지 않음</text>
<rect x="42" y="540" width="530" height="104" rx="10" class="driver"/>
<text x="68" y="580" class="title">DDS로 Bearer 적용 시 필요한 경로</text>
<text x="68" y="610" class="text">지원 드라이버 또는 호출 앱 계층이</text>
<text x="68" y="634" class="text">DB 호출 전에 EndUserSecurityContext Attach</text>
<line x1="572" y1="592" x2="642" y2="592" class="arrow"/>
<rect x="658" y="540" width="540" height="104" rx="10" class="ok"/>
<text x="684" y="580" class="title">DDS DATA GRANT 적용</text>
<text x="684" y="610" class="text">DB가 End-user Identity와 DATA ROLE 인식</text>
<text x="684" y="634" class="text">보호 객체(VIEW/TABLE) 조회 허용</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,88 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="760" viewBox="0 0 1280 760" role="img" aria-labelledby="title desc">
<title id="title">DDS 보안 오브젝트 모델</title>
<desc id="desc">END USER, APPLICATION IDENTITY, DATA ROLE, DATA GRANT, 보호 객체와 중앙 확인 뷰의 관계.</desc>
<rect width="1280" height="760" fill="#f8fafc"/>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 z" fill="#475569"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 34px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 19px; fill: #475569; }
.label { font-family: Arial, Helvetica, sans-serif; font-size: 15px; font-weight: 700; fill: #475569; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 17px; fill: #334155; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #1e293b; }
.card { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; }
.identity { fill: #eff6ff; stroke: #2563eb; stroke-width: 2; }
.role { fill: #f5f3ff; stroke: #7c3aed; stroke-width: 2; }
.grant { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.object { fill: #fff7ed; stroke: #ea580c; stroke-width: 2; }
.audit { fill: #eef2ff; stroke: #4f46e5; stroke-width: 2; }
.deny { fill: #fef2f2; stroke: #dc2626; stroke-width: 2; }
.chip { fill: #ffffff; stroke: #bbf7d0; stroke-width: 1.5; }
.arrow { stroke: #475569; stroke-width: 2.2; marker-end: url(#arrow); fill: none; }
.dash { stroke: #64748b; stroke-width: 2; stroke-dasharray: 7 6; marker-end: url(#arrow); fill: none; }
</style>
</defs>
<text x="48" y="58" class="h1">DDS 보안 오브젝트 모델</text>
<text x="48" y="90" class="sub">권한은 업무 매핑 테이블이 아니라 Oracle 보안 오브젝트와 DATA GRANT로 선언</text>
<text x="48" y="130" class="label">사용자 식별</text>
<rect x="48" y="146" width="270" height="132" rx="8" class="identity"/>
<text x="74" y="184" class="title">END USER</text>
<text x="74" y="222" class="text">스키마를 소유하지 않는</text>
<text x="74" y="248" class="text">DDS 보안 사용자</text>
<rect x="48" y="330" width="270" height="112" rx="8" class="card"/>
<text x="74" y="366" class="title">APPLICATION IDENTITY</text>
<text x="74" y="402" class="text">애플리케이션 자체 권한이</text>
<text x="74" y="426" class="text">필요할 때 쓰는 확장</text>
<text x="390" y="130" class="label">역할 묶음</text>
<rect x="390" y="146" width="270" height="132" rx="8" class="role"/>
<text x="416" y="184" class="title">DATA ROLE</text>
<text x="416" y="222" class="text">데이터 권한 묶음</text>
<text x="416" y="248" class="mono">CB_DDS_HR_ROLE</text>
<text x="730" y="130" class="label">권한 선언</text>
<rect x="730" y="118" width="420" height="244" rx="8" class="grant"/>
<text x="760" y="158" class="title">DATA GRANT</text>
<text x="760" y="194" class="text">보호 객체에 대한 작업, 행, 컬럼 범위</text>
<rect x="760" y="218" width="358" height="36" rx="6" class="chip"/>
<text x="780" y="242" class="mono">AS SELECT</text>
<rect x="760" y="264" width="358" height="36" rx="6" class="chip"/>
<text x="780" y="288" class="mono">WHERE dept_code = 'HR'</text>
<rect x="760" y="310" width="358" height="36" rx="6" class="chip"/>
<text x="780" y="334" class="mono">ALL COLUMNS EXCEPT contents</text>
<text x="730" y="410" class="label">보호 대상</text>
<rect x="730" y="426" width="420" height="114" rx="8" class="object"/>
<text x="760" y="466" class="title">VIEW / TABLE</text>
<text x="760" y="502" class="mono">ADMIN.CB_DDS_V_SEARCH_DOCUMENTS</text>
<text x="760" y="526" class="text">DATA GRANT가 있는 범위만 조회 가능</text>
<line x1="318" y1="212" x2="382" y2="212" class="arrow"/>
<text x="326" y="197" class="label">GRANT</text>
<path d="M 318 386 C 350 386, 356 246, 382 230" class="dash"/>
<text x="330" y="352" class="label">선택 확장</text>
<line x1="660" y1="212" x2="722" y2="212" class="arrow"/>
<text x="684" y="197" class="label">TO</text>
<path d="M 940 362 L 940 418" class="arrow"/>
<text x="954" y="398" class="label">ON</text>
<rect x="48" y="574" width="540" height="104" rx="8" class="deny"/>
<text x="74" y="614" class="title">DATA GRANT 없음</text>
<text x="74" y="650" class="text">권한 미부여 END USER에게는 객체 자체가 보이지 않음</text>
<text x="74" y="674" class="mono">ORA-00942</text>
<rect x="640" y="574" width="510" height="104" rx="8" class="audit"/>
<text x="666" y="614" class="title">중앙 확인</text>
<text x="666" y="650" class="mono">DBA_DATA_ROLE_GRANTS</text>
<text x="666" y="674" class="mono">DBA_DATA_GRANTS / DBA_DATA_ROLES</text>
<rect x="48" y="704" width="1102" height="42" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
<text x="74" y="731" class="text">요점: DDS는 사용자별 권한을 DATA ROLE과 DATA GRANT로 선언하고, Dictionary View로 적용 상태를 확인한다.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="620" viewBox="0 0 1120 620" role="img" aria-labelledby="title desc">
<title id="title">DDS 결과 화면</title>
<desc id="desc">DDS 권한이 없을 때 조회 대상이 보이지 않는 흐름을 요약한 화면.</desc>
<rect width="1120" height="620" fill="#f4f6f8"/>
<rect x="34" y="30" width="1052" height="560" rx="10" fill="#0f172a"/>
<circle cx="68" cy="58" r="7" fill="#ef4444"/>
<circle cx="92" cy="58" r="7" fill="#f59e0b"/>
<circle cx="116" cy="58" r="7" fill="#22c55e"/>
<text x="150" y="64" fill="#d1d5db" font-family="Menlo, Consolas, monospace" font-size="18">sqlplus - DDS DATA GRANT 테스트</text>
<text x="58" y="112" fill="#a7f3d0" font-family="Menlo, Consolas, monospace" font-size="20">=== DDS 사용자 확인 ===</text>
<text x="58" y="154" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">END_USER_NAME</text>
<text x="58" y="188" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">"cb_dds_hr"</text>
<text x="58" y="246" fill="#a7f3d0" font-family="Menlo, Consolas, monospace" font-size="20">=== DDS 보호 객체 조회 결과 ===</text>
<text x="58" y="288" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">OBJECT_NAME ROWS_VISIBLE CONTENTS</text>
<text x="58" y="322" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">CB_DDS_V_SEARCH_DOCUMENTS 3 NULL</text>
<text x="58" y="356" fill="#fca5a5" font-family="Menlo, Consolas, monospace" font-size="18">cb_dds_none same object ORA-00942: 조회 대상 없음</text>
<text x="58" y="414" fill="#a7f3d0" font-family="Menlo, Consolas, monospace" font-size="20">=== 우회 시도 결과 ===</text>
<text x="58" y="456" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">ADMIN.CB_DDS_DOCUMENTS 직접 조회 - ORA-00942</text>
<text x="58" y="490" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">ADMIN.CB_SEARCH_DOCUMENTS 직접 조회 - ORA-00942</text>
<text x="58" y="524" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">ORDS Bearer 단독 DDS 검증 - EXPECTED_BLOCKED</text>
<text x="58" y="558" fill="#86efac" font-family="Menlo, Consolas, monospace" font-size="18">결과: DATA GRANT가 없으면 행 0건이 아니라 객체 자체가 숨겨짐</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,97 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="780" viewBox="0 0 1280 780" role="img" aria-labelledby="title desc">
<title id="title">DDS 설정과 확인 흐름</title>
<desc id="desc">DDS 보호 객체 준비, END USER와 DATA ROLE 생성, DATA GRANT 선언, 중앙 권한 인벤토리 확인 흐름.</desc>
<rect width="1280" height="780" fill="#f8fafc"/>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 z" fill="#475569"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 34px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 19px; fill: #475569; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 21px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 17px; fill: #334155; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #1e293b; }
.head { font-family: Arial, Helvetica, sans-serif; font-size: 15px; font-weight: 700; fill: #334155; }
.cell { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #1e293b; }
.card { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; }
.blue { fill: #eff6ff; stroke: #2563eb; stroke-width: 2; }
.purple { fill: #f5f3ff; stroke: #7c3aed; stroke-width: 2; }
.green { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.orange { fill: #fff7ed; stroke: #ea580c; stroke-width: 2; }
.audit { fill: #eef2ff; stroke: #4f46e5; stroke-width: 2; }
.arrow { stroke: #475569; stroke-width: 2.2; marker-end: url(#arrow); fill: none; }
.rule { stroke: #cbd5e1; stroke-width: 1.5; }
</style>
</defs>
<text x="48" y="58" class="h1">DDS 설정과 확인 흐름</text>
<text x="48" y="90" class="sub">적용은 DATA GRANT 단위로 선언하고, 확인은 Dictionary View에서 통합 조회</text>
<rect x="48" y="128" width="510" height="100" rx="8" class="blue"/>
<text x="74" y="164" class="title">1. 보호 객체 준비</text>
<text x="74" y="198" class="mono">CREATE VIEW / TABLE admin.cb_dds_v_search_documents</text>
<line x1="303" y1="228" x2="303" y2="252" class="arrow"/>
<rect x="48" y="260" width="510" height="112" rx="8" class="purple"/>
<text x="74" y="296" class="title">2. 사용자와 역할 생성</text>
<text x="74" y="330" class="mono">CREATE END USER "cb_dds_hr"</text>
<text x="74" y="356" class="mono">CREATE DATA ROLE cb_dds_hr_role</text>
<line x1="303" y1="372" x2="303" y2="396" class="arrow"/>
<rect x="48" y="404" width="510" height="178" rx="8" class="green"/>
<text x="74" y="440" class="title">3. DATA GRANT 선언</text>
<text x="74" y="474" class="mono">CREATE DATA GRANT admin.cb_dg_hr_docs</text>
<text x="74" y="500" class="mono">AS SELECT (ALL COLUMNS EXCEPT contents)</text>
<text x="74" y="526" class="mono">ON admin.cb_dds_v_search_documents</text>
<text x="74" y="552" class="mono">WHERE dept_code = 'HR' TO cb_dds_hr_role</text>
<line x1="303" y1="582" x2="303" y2="606" class="arrow"/>
<rect x="48" y="614" width="510" height="96" rx="8" class="orange"/>
<text x="74" y="650" class="title">4. 조회 시 자동 적용</text>
<text x="74" y="684" class="text">허용 행과 컬럼만 반환. 권한 미부여 시 ORA-00942.</text>
<rect x="638" y="128" width="582" height="582" rx="8" class="audit"/>
<text x="670" y="166" class="title">5. 권한 인벤토리 확인</text>
<text x="670" y="198" class="text">적용 상태는 아래 Dictionary View를 조합해 확인</text>
<rect x="670" y="224" width="250" height="64" rx="6" class="card"/>
<text x="692" y="250" class="mono">DBA_DATA_GRANTS</text>
<text x="692" y="274" class="text">객체, 조건, 컬럼 범위</text>
<rect x="948" y="224" width="250" height="64" rx="6" class="card"/>
<text x="970" y="250" class="mono">DBA_DATA_ROLE_GRANTS</text>
<text x="970" y="274" class="text">END USER와 역할 매핑</text>
<text x="670" y="332" class="head">END_USER</text>
<text x="832" y="332" class="head">DATA_ROLE</text>
<text x="1012" y="332" class="head">ROW / COLUMN</text>
<line x1="670" y1="346" x2="1198" y2="346" class="rule"/>
<text x="670" y="382" class="cell">cb_dds_hr</text>
<text x="832" y="382" class="cell">HR_ROLE</text>
<text x="1012" y="382" class="cell">HR / EXCEPT contents</text>
<line x1="670" y1="402" x2="1198" y2="402" class="rule"/>
<text x="670" y="438" class="cell">cb_dds_fin</text>
<text x="832" y="438" class="cell">FIN_ROLE</text>
<text x="1012" y="438" class="cell">FIN / EXCEPT contents</text>
<line x1="670" y1="458" x2="1198" y2="458" class="rule"/>
<text x="670" y="494" class="cell">cb_dds_all</text>
<text x="832" y="494" class="cell">ALL_ROLE</text>
<text x="1012" y="494" class="cell">ALL / ALL COLUMNS</text>
<line x1="670" y1="514" x2="1198" y2="514" class="rule"/>
<text x="670" y="550" class="cell">cb_dds_none</text>
<text x="832" y="550" class="cell">CONNECT_ONLY</text>
<text x="1012" y="550" class="cell">- / ORA-00942</text>
<rect x="670" y="610" width="528" height="72" rx="8" fill="#ffffff" stroke="#c7d2fe" stroke-width="2"/>
<text x="694" y="638" class="title">운영 포인트</text>
<text x="694" y="666" class="text">승인 사유와 ticket 번호는 별도 이력 테이블에 보관</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,85 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="title desc">
<title id="title">ORDS Agent 데이터 접근 통제 아키텍처</title>
<desc id="desc">Agent 요청에서 ORDS 사용자 식별, DB 보안 정책 적용, 허용 결과 반환까지의 아키텍처와 DB User, Bearer Key, VPD, DDS 적용 경로.</desc>
<rect width="1280" height="720" fill="#f8fafc"/>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#334155"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 36px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 20px; fill: #475569; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 21px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 17px; fill: #334155; }
.small { font-family: Arial, Helvetica, sans-serif; font-size: 15px; fill: #475569; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #1e293b; }
.trunk { fill: #ffffff; stroke: #2563eb; stroke-width: 2; rx: 10; }
.branch { fill: #fefce8; stroke: #ca8a04; stroke-width: 2; rx: 10; }
.policy { fill: #ecfdf5; stroke: #059669; stroke-width: 2; rx: 10; }
.result { fill: #eef2ff; stroke: #4f46e5; stroke-width: 2; rx: 10; }
</style>
</defs>
<text x="54" y="62" class="h1">Agent 권한 매핑 및 데이터 접근 통제</text>
<text x="54" y="100" class="sub">사용자 식별 방식은 갈라지지만, 최종 데이터 제한은 보호 객체(VIEW/TABLE)에서 DB가 적용한다.</text>
<rect x="42" y="154" width="150" height="96" class="trunk"/>
<text x="66" y="192" class="title">1. Agent</text>
<text x="66" y="222" class="text">검색 요청</text>
<line x1="192" y1="202" x2="250" y2="202" stroke="#334155" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="252" y="154" width="160" height="96" class="trunk"/>
<text x="276" y="192" class="title">2. ORDS</text>
<text x="276" y="222" class="text">요청 수신</text>
<line x1="412" y1="202" x2="470" y2="202" stroke="#334155" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="472" y="154" width="190" height="96" class="trunk"/>
<text x="496" y="188" class="title">3. 사용자 식별</text>
<text x="496" y="218" class="text">DB User 또는 Key</text>
<line x1="662" y1="202" x2="720" y2="202" stroke="#334155" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="722" y="154" width="210" height="96" class="trunk"/>
<text x="746" y="188" class="title">4. 권한 기준 생성</text>
<text x="746" y="218" class="text">Context / DDS Identity</text>
<line x1="932" y1="202" x2="990" y2="202" stroke="#334155" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="992" y="154" width="230" height="96" class="trunk"/>
<text x="1016" y="188" class="title">5. 보호 객체</text>
<text x="1016" y="216" class="text">VIEW/TABLE 조회</text>
<text x="1016" y="239" class="text">정책 적용</text>
<rect x="446" y="318" width="245" height="126" class="branch"/>
<text x="474" y="356" class="title">식별 경로 A</text>
<text x="474" y="386" class="mono">SESSION_USER</text>
<text x="474" y="416" class="text">DB 계정으로 사용자 매핑</text>
<line x1="568" y1="318" x2="568" y2="250" stroke="#ca8a04" stroke-width="2.5" marker-end="url(#arrow)"/>
<rect x="156" y="318" width="245" height="126" class="branch"/>
<text x="184" y="356" class="title">식별 경로 B</text>
<text x="184" y="386" class="mono">Authorization: Bearer</text>
<text x="184" y="416" class="text">Key로 내부 사용자 매핑</text>
<line x1="400" y1="372" x2="472" y2="235" stroke="#ca8a04" stroke-width="2.5" marker-end="url(#arrow)"/>
<rect x="736" y="318" width="245" height="126" class="branch"/>
<text x="764" y="356" class="title">식별 경로 C</text>
<text x="764" y="386" class="mono">END USER</text>
<text x="764" y="416" class="text">DDS 보안 사용자</text>
<line x1="828" y1="318" x2="828" y2="250" stroke="#ca8a04" stroke-width="2.5" marker-end="url(#arrow)"/>
<rect x="230" y="508" width="340" height="126" class="policy"/>
<text x="260" y="548" class="title">VPD 적용 경로</text>
<text x="260" y="578" class="text">SYS_CONTEXT + p_object + EXISTS</text>
<text x="260" y="606" class="small">권한 테이블 기반 행 제한</text>
<text x="260" y="626" class="small">Redaction 값 마스킹</text>
<line x1="570" y1="562" x2="746" y2="250" stroke="#059669" stroke-width="2.5" marker-end="url(#arrow)"/>
<rect x="710" y="508" width="340" height="126" class="policy"/>
<text x="740" y="548" class="title">DDS 적용 경로</text>
<text x="740" y="578" class="text">DATA ROLE + DATA GRANT</text>
<text x="740" y="606" class="small">행 / 컬럼 / 작업 통제</text>
<text x="740" y="626" class="small">선언형 제한</text>
<line x1="880" y1="508" x2="1048" y2="250" stroke="#059669" stroke-width="2.5" marker-end="url(#arrow)"/>
<rect x="368" y="654" width="544" height="48" class="result"/>
<text x="410" y="685" class="title">결과: 허용된 행과 컬럼만 반환</text>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="760" viewBox="0 0 1280 760" role="img" aria-labelledby="title desc">
<title id="title">권한 미부여 및 차단 결과 화면</title>
<desc id="desc">권한 미부여 시 VPD 0건, DB 권한 오류, Bearer Key 오류가 어떻게 다른지 보여주는 화면.</desc>
<rect width="1280" height="760" fill="#f4f6f8"/>
<rect x="36" y="30" width="1208" height="700" rx="10" fill="#172033"/>
<circle cx="70" cy="58" r="7" fill="#ef4444"/>
<circle cx="94" cy="58" r="7" fill="#f59e0b"/>
<circle cx="118" cy="58" r="7" fill="#22c55e"/>
<text x="152" y="64" fill="#d1d5db" font-family="Menlo, Consolas, monospace" font-size="18">권한 미부여 및 차단 결과 비교</text>
<text x="60" y="116" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== Case A. DB SELECT Grant 있음 + VPD Policy 있음 + 매핑 없음 ===</text>
<text x="60" y="158" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">SELECT COUNT(*) FROM ADMIN.CB_V_SEARCH_DOCUMENTS;</text>
<text x="60" y="196" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="17">ROWS_VISIBLE</text>
<text x="60" y="226" fill="#86efac" font-family="Menlo, Consolas, monospace" font-size="17">0</text>
<text x="232" y="226" fill="#c7d2fe" font-family="Menlo, Consolas, monospace" font-size="17">해석: SQL은 실행되지만 VPD EXISTS가 통과하지 못함</text>
<text x="60" y="294" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== Case B. 보호 객체 DB 권한 미부여 ===</text>
<text x="60" y="336" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">SELECT COUNT(*) FROM ADMIN.CB_V_SEARCH_DOCUMENTS;</text>
<text x="60" y="374" fill="#fca5a5" font-family="Menlo, Consolas, monospace" font-size="17">ORA-00942: table or view does not exist</text>
<text x="472" y="374" fill="#c7d2fe" font-family="Menlo, Consolas, monospace" font-size="17">해석: VPD 판단 전 객체가 보이지 않음</text>
<text x="60" y="442" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== Case C. Bearer Key 누락 또는 형식 오류 ===</text>
<text x="60" y="484" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">Authorization Header 없음</text>
<text x="60" y="522" fill="#fca5a5" font-family="Menlo, Consolas, monospace" font-size="17">ORA-20101: Authorization header must be Bearer &lt;key&gt;</text>
<text x="628" y="522" fill="#c7d2fe" font-family="Menlo, Consolas, monospace" font-size="17">해석: ORDS Handler 필수값 차단</text>
<text x="60" y="590" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== Case D. Bearer Key가 틀리거나 만료됨 ===</text>
<text x="60" y="632" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">Authorization: Bearer invalid_key</text>
<text x="60" y="670" fill="#fca5a5" font-family="Menlo, Consolas, monospace" font-size="17">ORA-20002: Invalid or expired Bearer key</text>
<text x="472" y="670" fill="#c7d2fe" font-family="Menlo, Consolas, monospace" font-size="17">해석: Key Hash 매핑 실패, Context 초기화</text>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="760" viewBox="0 0 1280 760" role="img" aria-labelledby="title desc">
<title id="title">권한 등록 매핑 화면</title>
<desc id="desc">Bearer Key 사용자를 Role, Permission, 행 규칙으로 등록하는 운영 화면.</desc>
<rect width="1280" height="760" fill="#f4f6f8"/>
<rect x="36" y="30" width="1208" height="700" rx="10" fill="#111827"/>
<circle cx="70" cy="58" r="7" fill="#ef4444"/>
<circle cx="94" cy="58" r="7" fill="#f59e0b"/>
<circle cx="118" cy="58" r="7" fill="#22c55e"/>
<text x="152" y="64" fill="#d1d5db" font-family="Menlo, Consolas, monospace" font-size="18">ADMIN - Agent 권한 등록 매핑</text>
<text x="60" y="112" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== 1. 내부 사용자와 Bearer Key 등록 ===</text>
<text x="60" y="154" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">APP_USER: USER_ID=101, USER_NAME=agent_hr, EMP_NO=E10234, DEPT_CODE=HR, READ_CONTENTS=N</text>
<text x="60" y="188" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">AGENT_BEARER_KEY: KEY_ID=1, USER_ID=101, KEY_HASH=SHA256(cb_hr_key), ACTIVE=Y</text>
<text x="60" y="244" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== 2. 사용자 -> 역할 -> 보호 객체 권한 ===</text>
<text x="60" y="286" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">USER_ROLE: USER_ID=101 -> ROLE_ID=10</text>
<text x="60" y="320" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">APP_ROLE: ROLE_ID=10 -> HR_SEARCH_ROLE</text>
<text x="60" y="354" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">PERMISSION: ROLE_ID=10 -> TARGET_NAME=CB_V_SEARCH_DOCUMENTS, ACTION=SELECT</text>
<text x="60" y="410" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== 3. 행 / 컬럼 제한 ===</text>
<text x="60" y="452" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">PERMISSION_RULE: PERM_ID=100, RULE_TYPE=MY_DEPT, RULE_VALUE=HR</text>
<text x="60" y="486" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="17">COLUMN RULE: APP_USER.CAN_READ_CONTENTS=N -> DBMS_REDACT가 CONTENTS를 NULL 처리</text>
<text x="60" y="542" fill="#bfdbfe" font-family="Menlo, Consolas, monospace" font-size="20">=== 4. 조회 시 VPD 매칭 ===</text>
<text x="60" y="584" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="17">p_object=CB_V_SEARCH_DOCUMENTS</text>
<text x="60" y="618" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="17">permission.target_name=CB_V_SEARCH_DOCUMENTS</text>
<text x="60" y="652" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="17">rule_type=MY_DEPT + SYS_CONTEXT(DEPT_CODE)=HR -> HR 행만 반환</text>
<text x="60" y="696" fill="#86efac" font-family="Menlo, Consolas, monospace" font-size="18">결과: Key User의 role/permission/rule이 있어야 행이 반환됨</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="700" viewBox="0 0 1280 700" role="img" aria-labelledby="title desc">
<title id="title">SYS_CONTEXT 동작 방식</title>
<desc id="desc">ORDS 처리 로직이 DB 세션에 값을 저장하고 VPD 함수가 SYS_CONTEXT로 값을 읽어 EXISTS 권한 조회에 사용하는 흐름.</desc>
<rect width="1280" height="700" fill="#f8fafc"/>
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto">
<path d="M 0 0 L 9 4.5 L 0 9 z" fill="#475569"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 39px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 21px; fill: #475569; }
.card { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; }
.blue { fill: #eff6ff; stroke: #2563eb; stroke-width: 2; }
.purple { fill: #f5f3ff; stroke: #7c3aed; stroke-width: 2; }
.green { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.warn { fill: #fff7ed; stroke: #ea580c; stroke-width: 2; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 24px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 18px; fill: #334155; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #0f172a; }
.arrow { stroke: #475569; stroke-width: 2.5; marker-end: url(#arrow); fill: none; }
</style>
</defs>
<text x="48" y="58" class="h1">SYS_CONTEXT 동작 방식</text>
<text x="48" y="94" class="sub">현재 DB 세션에 저장된 요청자 값을 읽어 권한 테이블 EXISTS 조회에 사용</text>
<rect x="48" y="142" width="320" height="152" rx="16" class="blue"/>
<text x="76" y="184" class="title">1. ORDS 처리 로직</text>
<text x="76" y="224" class="text">DB 계정 또는 Bearer Key로</text>
<text x="76" y="252" class="text">요청자를 식별</text>
<line x1="368" y1="218" x2="438" y2="218" class="arrow"/>
<rect x="450" y="142" width="360" height="152" rx="16" class="purple"/>
<text x="478" y="184" class="title">2. DB 세션에 값 저장</text>
<text x="478" y="224" class="mono">DBMS_SESSION.SET_CONTEXT</text>
<text x="478" y="252" class="mono">AGENT_CTX.EMP_NO = E10234</text>
<text x="478" y="278" class="mono">AGENT_CTX.DEPT_CODE = HR</text>
<line x1="810" y1="218" x2="850" y2="218" class="arrow"/>
<rect x="862" y="142" width="370" height="152" rx="10" class="green"/>
<text x="890" y="184" class="title">3. EXISTS 조회에 사용</text>
<text x="890" y="224" class="mono">SYS_CONTEXT('AGENT_CTX','EMP_NO')</text>
<text x="890" y="252" class="mono">SYS_CONTEXT('AGENT_CTX','DEPT_CODE')</text>
<rect x="118" y="380" width="500" height="126" rx="10" class="card"/>
<text x="150" y="424" class="title">같은 DB 세션 안에서만 유효</text>
<text x="150" y="462" class="text">요청마다 기존 값을 지우고 새 값을 저장</text>
<text x="150" y="490" class="text">다른 요청자 정보가 섞이지 않도록 처리</text>
<rect x="682" y="380" width="500" height="126" rx="10" class="warn"/>
<text x="714" y="424" class="title">직접 조작 방지</text>
<text x="714" y="462" class="text">CONTEXT는 지정된 패키지를 통해서만 설정</text>
<text x="714" y="490" class="text">일반 사용자가 임의로 값을 바꾸지 못하게 구성</text>
<rect x="220" y="574" width="840" height="54" rx="14" fill="#eef2ff" stroke="#4f46e5" stroke-width="2.5"/>
<text x="250" y="609" class="title">요점: SET_CONTEXT는 저장, SYS_CONTEXT는 조회, EXISTS가 권한 판단</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,105 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" role="img" aria-labelledby="title desc">
<title id="title">두 가지 사용자 식별 및 권한 매핑 시나리오</title>
<desc id="desc">DB User 기반과 Bearer Key 기반의 사용자 식별 차이와 공통 DB 보안 적용 흐름.</desc>
<rect width="1280" height="800" fill="#f8fafc"/>
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto">
<path d="M 0 0 L 9 4.5 L 0 9 z" fill="#475569"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 36px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 20px; fill: #475569; }
.panel { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; }
.panel-a { fill: #f8fbff; stroke: #2563eb; stroke-width: 2; }
.panel-b { fill: #fffaf5; stroke: #ea580c; stroke-width: 2; }
.shared { fill: #f7f5ff; stroke: #7c3aed; stroke-width: 2; }
.result { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 24px; font-weight: 700; fill: #0f172a; }
.label { font-family: Arial, Helvetica, sans-serif; font-size: 18px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 17px; fill: #334155; }
.small { font-family: Arial, Helvetica, sans-serif; font-size: 16px; fill: #475569; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #1e293b; }
.warn { font-family: Arial, Helvetica, sans-serif; font-size: 16px; font-weight: 700; fill: #b91c1c; }
.arrow { stroke: #475569; stroke-width: 2.2; marker-end: url(#arrow); fill: none; }
</style>
</defs>
<text x="48" y="58" class="h1">두 가지 사용자 식별 시나리오</text>
<text x="48" y="94" class="sub">사용자 식별 방식은 다르고, 권한 적용은 DB 보안 정책에서 동일하게 수행</text>
<rect x="48" y="128" width="572" height="292" rx="10" class="panel-a"/>
<text x="76" y="176" class="title">시나리오 1 - DB User 기반</text>
<rect x="76" y="204" width="156" height="118" rx="10" class="panel"/>
<text x="96" y="240" class="label">DB 접속 계정</text>
<text x="96" y="276" class="mono">SESSION_USER</text>
<text x="96" y="304" class="mono">AGENT_HR_001</text>
<line x1="232" y1="263" x2="270" y2="263" class="arrow"/>
<rect x="276" y="204" width="164" height="118" rx="10" class="panel"/>
<text x="296" y="240" class="label">사용자 매핑</text>
<text x="296" y="276" class="mono">app_user</text>
<text x="296" y="304" class="mono">user_role</text>
<line x1="440" y1="263" x2="478" y2="263" class="arrow"/>
<rect x="484" y="204" width="88" height="118" rx="10" class="panel"/>
<text x="506" y="240" class="label">식별</text>
<text x="504" y="276" class="mono">사번</text>
<text x="504" y="304" class="mono">부서</text>
<rect x="76" y="346" width="496" height="42" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="2"/>
<text x="96" y="374" class="small">DB 계정별 권한 등록. DBA가 사용자-역할-권한 테이블 관리</text>
<rect x="660" y="128" width="572" height="292" rx="10" class="panel-b"/>
<text x="688" y="176" class="title">시나리오 2 - Bearer Key 기반</text>
<rect x="688" y="204" width="156" height="118" rx="10" class="panel"/>
<text x="708" y="240" class="label">ORDS Header</text>
<text x="708" y="276" class="mono">Bearer Key</text>
<text x="708" y="304" class="warn">없으면 차단</text>
<line x1="844" y1="263" x2="882" y2="263" class="arrow"/>
<rect x="888" y="204" width="164" height="118" rx="10" class="panel"/>
<text x="908" y="240" class="label">Key 검증</text>
<text x="908" y="276" class="mono">key_hash</text>
<text x="908" y="304" class="mono">agent_key</text>
<line x1="1052" y1="263" x2="1090" y2="263" class="arrow"/>
<rect x="1096" y="204" width="88" height="118" rx="10" class="panel"/>
<text x="1118" y="240" class="label">식별</text>
<text x="1116" y="276" class="mono">사번</text>
<text x="1116" y="304" class="mono">부서</text>
<rect x="688" y="346" width="496" height="42" rx="8" fill="#fff7ed" stroke="#fed7aa" stroke-width="2"/>
<text x="708" y="374" class="small">ORDS 처리 로직이 Header 값을 받아 내부 사용자로 매핑</text>
<path d="M 334 420 C 334 452, 438 468, 520 492" class="arrow"/>
<path d="M 946 420 C 946 452, 842 468, 760 492" class="arrow"/>
<rect x="178" y="494" width="430" height="110" rx="10" class="shared"/>
<text x="214" y="538" class="title">현재 요청 사용자 정보</text>
<text x="214" y="574" class="text">USER_ID / 사번 / 부서 저장</text>
<rect x="672" y="494" width="450" height="110" rx="10" class="shared"/>
<text x="708" y="538" class="title">보호 객체 조회</text>
<text x="708" y="574" class="text">VIEW 또는 TABLE에 연결된 정책 적용</text>
<line x1="640" y1="604" x2="640" y2="636" class="arrow"/>
<rect x="178" y="638" width="450" height="76" rx="14" class="shared"/>
<text x="212" y="684" class="label">VPD: WHERE 조건 자동 추가</text>
<rect x="672" y="638" width="450" height="76" rx="14" class="shared"/>
<text x="706" y="684" class="label">DDS: DATA GRANT WHERE 적용</text>
<line x1="628" y1="676" x2="672" y2="676" class="arrow"/>
<line x1="640" y1="714" x2="640" y2="732" class="arrow"/>
<rect x="420" y="730" width="440" height="48" rx="12" class="result"/>
<text x="512" y="762" class="label">결과: 권한 범위 데이터만 반환</text>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="620" viewBox="0 0 1120 620" role="img" aria-labelledby="title desc">
<title id="title">VPD 결과 화면</title>
<desc id="desc">VPD 행 필터링과 우회 시도 차단 결과를 요약한 화면.</desc>
<rect width="1120" height="620" fill="#f4f6f8"/>
<rect x="34" y="30" width="1052" height="560" rx="10" fill="#111827"/>
<circle cx="68" cy="58" r="7" fill="#ef4444"/>
<circle cx="92" cy="58" r="7" fill="#f59e0b"/>
<circle cx="116" cy="58" r="7" fill="#22c55e"/>
<text x="150" y="64" fill="#d1d5db" font-family="Menlo, Consolas, monospace" font-size="18">sqlplus - VPD 보안 정책 테스트</text>
<text x="58" y="112" fill="#93c5fd" font-family="Menlo, Consolas, monospace" font-size="20">=== DB 세션과 Key User Context ===</text>
<text x="58" y="154" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">DB_USER KEY_USER_ID EMP_NO DEPT_CODE READ_CONTENTS</text>
<text x="58" y="188" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">CB_ORDS 101 E10234 HR N</text>
<text x="58" y="246" fill="#93c5fd" font-family="Menlo, Consolas, monospace" font-size="20">=== CB_V_SEARCH_DOCUMENTS 조회 결과 ===</text>
<text x="58" y="288" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">KEY ROWS_VISIBLE CONTENTS</text>
<text x="58" y="322" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">cb_hr_key 3 NULL</text>
<text x="58" y="356" fill="#f9fafb" font-family="Menlo, Consolas, monospace" font-size="18">cb_all_key 6 원문 표시</text>
<text x="58" y="414" fill="#93c5fd" font-family="Menlo, Consolas, monospace" font-size="20">=== 우회 시도 결과 ===</text>
<text x="58" y="456" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">read ADMIN.CB_SEARCH_DOCUMENTS - ORA-00942</text>
<text x="58" y="490" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">read ADMIN.CB_AGENT_BEARER_KEY - ORA-00942</text>
<text x="58" y="524" fill="#e5e7eb" font-family="Menlo, Consolas, monospace" font-size="18">Invalid Bearer Key - ORA-20002</text>
<text x="58" y="558" fill="#86efac" font-family="Menlo, Consolas, monospace" font-size="18">결과: Agent가 아니라 DB가 최종 필터링</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="760" viewBox="0 0 1280 760" role="img" aria-labelledby="title desc">
<title id="title">VPD EXISTS 권한 조건 적용 흐름</title>
<desc id="desc">ORDS 조회 SQL에 VPD가 p_object 기준 EXISTS 권한 조건을 추가해 VIEW/TABLE 접근과 행 접근을 판단하는 흐름.</desc>
<rect width="1280" height="760" fill="#f8fafc"/>
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto">
<path d="M 0 0 L 9 4.5 L 0 9 z" fill="#475569"/>
</marker>
<style>
.h1 { font-family: Arial, Helvetica, sans-serif; font-size: 36px; font-weight: 700; fill: #0f172a; }
.sub { font-family: Arial, Helvetica, sans-serif; font-size: 20px; fill: #475569; }
.panel { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; }
.blue { fill: #eff6ff; stroke: #2563eb; stroke-width: 2; }
.purple { fill: #f5f3ff; stroke: #7c3aed; stroke-width: 2; }
.green { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.warn { fill: #fff7ed; stroke: #ea580c; stroke-width: 2; }
.title { font-family: Arial, Helvetica, sans-serif; font-size: 23px; font-weight: 700; fill: #0f172a; }
.text { font-family: Arial, Helvetica, sans-serif; font-size: 18px; fill: #334155; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 17px; fill: #0f172a; }
.mono-small { font-family: Menlo, Consolas, monospace; font-size: 15px; fill: #0f172a; }
.arrow { stroke: #475569; stroke-width: 2.2; marker-end: url(#arrow); fill: none; }
</style>
</defs>
<text x="48" y="58" class="h1">VPD: EXISTS로 권한 테이블 확인</text>
<text x="48" y="94" class="sub">p_object는 현재 조회 대상, SYS_CONTEXT는 요청자 식별값, EXISTS는 실제 권한 판단</text>
<rect x="48" y="138" width="360" height="276" rx="10" class="blue"/>
<text x="76" y="184" class="title">1. ORDS가 실행한 SQL</text>
<text x="76" y="228" class="text">권한 조건 없음</text>
<text x="76" y="278" class="mono">SELECT doc_id, title</text>
<text x="76" y="306" class="mono">FROM app.v_search_documents</text>
<text x="76" y="334" class="mono">WHERE contains_text = :q;</text>
<line x1="408" y1="276" x2="468" y2="276" class="arrow"/>
<rect x="480" y="138" width="360" height="276" rx="10" class="purple"/>
<text x="508" y="184" class="title">2. VPD 정책 함수</text>
<text x="508" y="226" class="text">조회 대상과 요청자 확인</text>
<text x="508" y="264" class="mono">p_object = 현재 VIEW/TABLE</text>
<text x="508" y="292" class="mono">USER_ID = SYS_CONTEXT(...)</text>
<text x="508" y="344" class="mono-small">RETURN EXISTS (...)</text>
<text x="508" y="370" class="mono-small">target_name = p_object</text>
<line x1="840" y1="276" x2="900" y2="276" class="arrow"/>
<rect x="912" y="138" width="360" height="276" rx="10" class="green"/>
<text x="940" y="184" class="title">3. DB가 합쳐서 적용</text>
<text x="940" y="226" class="text">조회 SQL 뒤에 권한 조건 추가</text>
<text x="940" y="276" class="mono-small">WHERE contains_text = :q</text>
<text x="940" y="304" class="mono-small">AND EXISTS (</text>
<text x="940" y="332" class="mono-small"> permission.target = p_object</text>
<text x="940" y="360" class="mono-small"> row rule matched)</text>
<rect x="74" y="486" width="360" height="118" rx="10" class="panel"/>
<text x="106" y="530" class="title">VIEW/TABLE 접근 판단</text>
<text x="106" y="568" class="text">target_name = p_object</text>
<text x="106" y="594" class="text">없으면 결과 0건</text>
<rect x="460" y="486" width="360" height="118" rx="10" class="panel"/>
<text x="492" y="530" class="title">행 접근 판단</text>
<text x="492" y="568" class="text">permission_rule이 행 컬럼과 일치</text>
<text x="492" y="594" class="text">조건에 맞는 행만 반환</text>
<rect x="846" y="486" width="360" height="118" rx="10" class="warn"/>
<text x="878" y="530" class="title">권한 매핑 없음</text>
<text x="878" y="568" class="text">EXISTS가 false</text>
<text x="878" y="594" class="text">해당 행은 제외</text>
<rect x="190" y="650" width="900" height="58" rx="10" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
<text x="236" y="686" class="title">요점: p_object는 조회 대상, SYS_CONTEXT는 요청자, EXISTS가 권한 판단</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,5 +1,30 @@
# Redmine #546 - 외부 VM 배포 설계 # Redmine #546 - 외부 VM 배포 설계
## 현재 기준 배포 대상
이 문서의 최신 운영 기준은 아래와 같다. 과거 `hermes`/`130.162.134.59` 기록은 초기 개발·실험 배포 이력으로만 본다.
| 구분 | 값 |
| --- | --- |
| 개발·빌드 VM | `hermes` |
| 공개 서비스 배포 VM | `opc@161.33.6.45` (`vnic-aidp-poc`) |
| 공개 주소 | `https://kb.cloud-handson.com` |
| DNS | `kb.cloud-handson.com → 161.33.6.45` |
| VM 서비스 | `vpd-backoffice.service` |
| 앱 수신 주소 | `127.0.0.1:8080` |
| 공개 프록시 | Nginx `80/443 → 127.0.0.1:8080` |
| 인증서 | Lets Encrypt / Certbot Nginx plugin |
| 앱 디렉터리 | `/home/opc/apps/vpd-backoffice` |
| Wallet 디렉터리 | `/home/opc/apps/vpd-backoffice/wallet` |
반복 배포 시 완료 판정은 반드시 공개 주소 기준으로 한다.
```bash
curl -k -sS https://kb.cloud-handson.com/login
```
`hermes` 내부의 `8082` 응답만 확인하고 완료 처리하지 않는다. `8082`는 과거/개발 배포 경로에 해당할 수 있다.
## 프로젝트 개요 ## 프로젝트 개요
VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하는 Spring Boot 관리 도구다. 사용자는 Oracle DB schema user가 아니라 Bearer Token으로 식별되는 application user이며, 사용자/그룹/역할/권한/행 규칙/컬럼 NULL 처리 설정이 VPD policy function과 ORDS 조회 결과에 반영된다. VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하는 Spring Boot 관리 도구다. 사용자는 Oracle DB schema user가 아니라 Bearer Token으로 식별되는 application user이며, 사용자/그룹/역할/권한/행 규칙/컬럼 NULL 처리 설정이 VPD policy function과 ORDS 조회 결과에 반영된다.
@@ -16,7 +41,8 @@ VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이
## 배포 방식 ## 배포 방식
- 기본 대상 SSH host는 `hermes`로 두되, 스크립트 인자로 다른 SSH alias를 받을 수 있게 한다. - 기본 개발/빌드 host는 `hermes`일 수 있으나, 공개 서비스 배포 대상은 `opc@161.33.6.45`다.
- 배포 스크립트의 기본값이 `hermes`인 경우, 운영 배포에서는 반드시 `--host` 또는 SSH alias가 `161.33.6.45`를 가리키는지 확인한다.
- 로컬에서 `mvn -DskipTests package`로 jar를 빌드한다. - 로컬에서 `mvn -DskipTests package`로 jar를 빌드한다.
- 원격 디렉토리 기본값은 `~/apps/vpd-backoffice`다. - 원격 디렉토리 기본값은 `~/apps/vpd-backoffice`다.
- 배포 패키지 구성: - 배포 패키지 구성:
@@ -39,9 +65,9 @@ VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이
- `scripts/deploy-backoffice-vm.sh`가 jar, `.env`, wallet, start/stop/status 스크립트를 원격에 설치할 수 있다. - `scripts/deploy-backoffice-vm.sh`가 jar, `.env`, wallet, start/stop/status 스크립트를 원격에 설치할 수 있다.
- 대상 alias가 없으면 명확하게 실패한다. - 대상 alias가 없으면 명확하게 실패한다.
- 배포 후 원격 `status.sh`와 HTTP `/login` 헬스체크가 가능하다. - 배포 후 원격 `status.sh`와 HTTP `/login` 헬스체크가 가능하다.
- OCI NSG와 VM firewalld에서 운영자 IP 기준 `8082/tcp` 접근을 허용한다. - 현재 공개 운영에서는 Nginx 80/443만 외부에 열고, Spring Boot 애플리케이션 포트는 VM 내부 loopback으로 제한한다.
## 배포 결과 ## 과거 hermes 배포 결과
- 배포 대상: `hermes` / `opc@130.162.134.59` - 배포 대상: `hermes` / `opc@130.162.134.59`
- 원격 경로: `/home/opc/apps/vpd-backoffice` - 원격 경로: `/home/opc/apps/vpd-backoffice`
@@ -77,8 +103,8 @@ VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이
- `mvn test` - `mvn test`
- `scripts/deploy-backoffice-vm.sh --dry-run` - `scripts/deploy-backoffice-vm.sh --dry-run`
- `ssh hermes` 접속 확인 - 운영 배포 대상 SSH alias가 `opc@161.33.6.45`를 가리키는지 확인
- `scripts/deploy-backoffice-vm.sh --host hermes --skip-build` - 운영 배포 대상의 `systemctl status vpd-backoffice` 확인
- 원격 내부 `/login` HTTP 200 - 원격 내부 `/login` HTTP 200
- 외부 `/login` HTTP 200 - 외부 `/login` HTTP 200
- `systemctl status vpd-backoffice``active (running)`이고 앱 로그에서 `vpd-backoffice-pool` 연결이 성공한다. - `systemctl status vpd-backoffice``active (running)`이고 앱 로그에서 `vpd-backoffice-pool` 연결이 성공한다.

View File

@@ -0,0 +1,200 @@
# 설계서: PoC_4 MCP Discovery UI — KB VPD Streamable HTTP 연동 정비 (#620)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-07-09
> **추적성** — Redmine: #620 · 관련 ADR: 없음
> · 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `config/mcp_servers.json`, `config/mcp_servers.sample.json`, VPD Backoffice의 `/mcp` endpoint · 테스트: PoC_4 단위 테스트 및 실제 MCP HTTP smoke test
## 1. 목적 (Why)
PoC_4의 MCP Discovery UI가 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하게 호출하면서도, 환경별 URL·전송 방식·오류 처리를 하나의 명확한 계약으로 관리한다.
현재 UI의 기본 호출 흐름은 동작한다. 다만 `kb_mcp` 설정에는 `endpoint_url``base_url_env`가 함께 있고, UI는 `endpoint_url`을 우선 사용한다. 따라서 `KB_MCP_BASE_URL`을 바꿔도 실제 호출 대상이 바뀌지 않는다. 또한 `custom_python`은 로컬 8500 MCP용 이름이므로 외부 KB VPD MCP의 통신 계약을 설명하지 못한다.
## 2. 범위 (Scope)
- **포함**:
- `apps/poc4/mcp_discovery_ui.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비
- `config/mcp_servers.json` 및 sample의 KB MCP 선언 정비
- KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화
- VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완
- **제외 (out of scope)**:
- 기존 `kb_vector_mcp``custom_python` 8500 RAG MCP의 변경
- VPD 권한 규칙, Select AI SQL 생성 규칙, ORDS 비즈니스 로직 변경
- OAuth Authorization Server, 사용자 로그인/토큰 발급 UI 재설계
- VPD 토큰 원문을 환경변수·레지스트리에 저장하는 방식
## 3. 인수조건 (Acceptance Criteria)
- [ ] KB MCP URL은 `KB_MCP_BASE_URL` 하나에서만 해석되고 `/mcp` path가 안전하게 결합된다.
- [ ] `initialize`, `notifications/initialized`, `tools/list`, `tools/call`의 모든 HTTP 요청에 현재 선택된 사용자의 `Authorization: Bearer <VPD token>`만 전송된다.
- [ ] Bearer 토큰은 JSON-RPC arguments, SQLite 대화 이력, UI 결과, 애플리케이션 로그에 저장·출력되지 않는다.
- [ ] 호출 가능한 도구는 `ords.query.kb_select_ai_vpd` 하나이며, 인자는 `prompt``limit`만 허용된다.
- [ ] 단일 도구 구성에서는 LLM router를 호출하지 않고 허용 도구를 직접 선택한다.
- [ ] 토큰 없음/위조/만료는 HTTP `401`, 유효 토큰의 권한 부족은 HTTP `403`으로 UI에 구분 표시된다.
- [ ] 잘못된 Origin의 브라우저 요청은 MCP 서버에서 거부되며, 허용 Origin 및 Origin 없는 네이티브 MCP client 정책이 문서화된다.
- [ ] 기존 `kb_vector_mcp` 및 로컬 8500 MCP 회귀 테스트가 통과한다.
## 4. 컨텍스트 & 제약
- KB VPD MCP endpoint: `https://kb.cloud-handson.com/mcp`
- 보호 대상: `ords.query.kb_select_ai_vpd`는 ORDS를 거쳐 VPD 컨텍스트가 적용된 Select AI 조회를 실행한다.
- 토큰 주체: VPD 권한은 정적 서비스 계정이 아니라 현재 선택된 `KB_STAKEHOLDERS` 사용자 토큰에 의해 결정된다.
- UI의 VPD token preset 파일은 데모 편의 기능일 뿐이다. 운영에서는 OS 소유자 전용 권한(`0600`)으로 관리하고 형상관리·로그·SQLite에서 제외한다.
- UI가 현재 사용하는 `2025-11-25` MCP protocol version과 서버의 지원 버전은 handshake에서 협상해야 한다. 지원하지 않는 버전을 무조건 강제하지 않는다.
- 현재 서버는 stateless JSON-RPC POST 호출로도 동작한다. 서버가 `Mcp-Session-Id`를 발급하면 client는 이후 요청에만 그 값을 포함한다.
## 5. 아키텍처 개요
I/O는 Discovery UI의 HTTP transport와 VPD Backoffice `/mcp`에 한정한다. URL 결합, 허용 도구 검증, 요청·응답 검증, 안전한 오류 변환은 순수 함수로 분리해 네트워크 없이 테스트한다.
```
VPD 사용자 선택 / 토큰 입력
│ (원문은 요청 메모리에만 존재)
PoC_4 MCP Discovery UI
├─ KB_MCP_BASE_URL + "/mcp"
├─ tool allowlist 검증
└─ Authorization: Bearer <current VPD token>
▼ HTTPS JSON-RPC / Streamable HTTP
VPD Backoffice MCP (/mcp)
├─ Origin·토큰 검증
├─ tools/list: metadata only
└─ tools/call: ords.query.kb_select_ai_vpd
ORDS Select AI API → VPD context → Oracle ADB
```
## 6. 데이터 모델
### 6.1 KB MCP registry 선언
`config/mcp_servers.json`의 KB 선언은 다음 계약을 따른다. 값은 예시이며, 토큰 값은 넣지 않는다.
```json
{
"id": "kb_mcp",
"enabled": true,
"provider": "kb_vpd_streamable_http",
"transport": "streamable_http",
"base_url_env": "KB_MCP_BASE_URL",
"endpoint_path": "/mcp",
"auth_delivery": "per_request_vpd_bearer",
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
"default_tool": "ords.query.kb_select_ai_vpd",
"tool_allowlist": ["ords.query.kb_select_ai_vpd"],
"router_mode": "direct",
"description": "KB VPD Select AI MCP; the current user's VPD bearer is sent only in the Authorization header."
}
```
환경 변수는 아래 두 값만 필요하다.
```dotenv
KB_MCP_BASE_URL=https://kb.cloud-handson.com
POC3_MCP_TIMEOUT_SECONDS=90
```
`endpoint_url`, `token_env`, `POC3_MCP_TOKEN`, `BACKOFFICE_MCP_ACCESS_TOKEN`은 KB MCP 선언에 두지 않는다. URL은 registry에 하드코딩하지 않고, VPD 토큰은 사용자별 요청에서만 받는다.
### 6.2 MCP 요청
모든 요청은 다음 헤더를 사용한다.
```http
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: <negotiated version>
Authorization: Bearer <current-user-vpd-token>
```
`tools/call` body의 `arguments`는 아래와 같이 제한한다.
```json
{
"name": "ords.query.kb_select_ai_vpd",
"arguments": {
"prompt": "담당 고객의 보험료 상세를 보여줘",
"limit": 50
}
}
```
경계 검증 규칙:
- tool name은 정확히 allowlist 값 하나와 일치해야 한다.
- `prompt`는 문자열이며 서버와 동일한 최대 길이를 적용한다.
- `limit`은 정수 `1..100`으로 clamp한다.
- 토큰은 공백·`Bearer ` prefix를 정규화한 뒤 헤더에만 넣는다.
- redirect는 허용하지 않는다. 다른 origin으로 Authorization이 전달되어서는 안 된다.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `resolve_kb_mcp_endpoint` | base URL과 고정 path를 안전하게 결합 | `(server, environ) -> str` | registry, env | HTTPS MCP URL | 누락/비정상 URL | 단순 |
| `validate_kb_mcp_server` | KB 선언의 provider·transport·allowlist를 검증 | `(mapping) -> McpServer` | registry row | typed server | 계약 위반 | 단순 |
| `mcp_headers` | 요청별 VPD bearer 헤더 생성 | `(token, version, session_id) -> dict` | 사용자 토큰 | 안전한 headers | 토큰 형식 오류 | 단순 |
| `discover_kb_tools` | initialize 및 tools/list 후 단일 도구를 검증 | `(server, token) -> McpDiscoveryResult` | endpoint, token | tool descriptor | auth/protocol 오류 | **복잡** |
| `call_kb_select_ai` | 고정 도구에 prompt·limit을 전달 | `(server, token, prompt, limit) -> result` | 사용자 요청 | tool result | auth/timeout/JSON-RPC 오류 | **복잡** |
| `to_public_mcp_error` | HTTP/JSON-RPC 오류를 안전한 UI 메시지로 변환 | `(exception) -> PublicMcpError` | 내부 오류 | 사용자 메시지 | 원문 노출 금지 | 단순 |
## 8. 흐름 / 알고리즘
1. UI는 `KB_MCP_BASE_URL`을 읽고 `/mcp`만 결합한다. registry의 임의 `endpoint_url`은 KB 서버에 허용하지 않는다.
2. 사용자가 VPD token preset 또는 일회성 Bearer를 선택한다. 토큰은 현재 실행 변수에만 유지한다.
3. client는 `initialize`를 보내고 서버가 반환한 protocol version과 선택 가능한 session ID를 검증한다.
4. `notifications/initialized`를 보낸 뒤 `tools/list`를 실행한다.
5. 응답 목록이 정확히 허용 도구를 포함하는지, 해당 input schema가 `prompt`, `limit` 계약에 맞는지 확인한다.
6. KB 서버는 단일 도구이므로 router model을 호출하지 않고 `ords.query.kb_select_ai_vpd`를 직접 선택한다.
7. `tools/call`은 prompt·clamp된 limit만 body에 넣고 VPD Bearer는 Authorization에만 넣는다.
8. 결과는 화면용 안전 projection만 SQLite에 저장한다. Authorization 헤더와 원문 token은 저장하지 않으며, 진단이 필요하면 단방향 token fingerprint만 별도 보존할 수 있다.
## 9. 엣지케이스 & 에러 처리
| 상황 | client 처리 | 서버 기대 동작 |
|------|-------------|----------------|
| 토큰 없음 | 호출 전 안내, 네트워크 요청 없음 | 해당 없음 |
| 토큰 위조·만료 | `401` → “토큰이 유효하지 않거나 만료됨” | `WWW-Authenticate` 포함 가능 |
| 유효하지만 권한 없음 | `403` → “이 사용자에게 조회 권한 없음” | VPD fail-closed 유지 |
| allowlist 밖 도구 | 호출 전 차단 | tools/call에서도 차단 |
| 429 | 안전하게 재시도하지 않고 잠시 후 재시도 안내 | rate limit 정책 적용 |
| timeout | tools/call 자동 재시도 금지 | request ID 기반 감사 추적 |
| session ID 미발급 | stateless POST로 진행 | session을 요구하지 않음 |
| session ID 발급 | 이후 요청에 `Mcp-Session-Id` 포함 | 세션 소유·만료 검증 |
| redirect | 즉시 실패 | Authorization 전달 금지 |
| Origin 불일치 | 브라우저 UI에 일반 오류 표시 | `403`으로 거부 |
## 10. 테스트 계획
- registry 단위 테스트
- `KB_MCP_BASE_URL`만으로 endpoint가 `https://kb.cloud-handson.com/mcp`가 되는지 검증
- KB registry에 `endpoint_url`, `token_env`, `custom_python`이 있으면 fail-closed 되는지 검증
- vector MCP 설정은 기존 형식으로 계속 로드되는지 검증
- HTTP transport 단위 테스트
- initialize/tools/list/tools/call 모두 Authorization header가 있고 JSON body에는 token key가 없는지 검증
- `401`, `403`, `429`, timeout, redirect, malformed JSON-RPC 응답을 안전한 메시지로 변환하는지 검증
- session header 반환/재전송 및 stateless fallback을 검증
- 통합 smoke test
- 허용된 VPD 사용자 토큰으로 `tools/list``tools/call` 성공
- 잘못된 토큰은 `401`, 타 사용자 권한은 `403`
- 설계사와 지점장 토큰으로 동일 질문을 실행해 VPD 행/컬럼 결과가 서로 다른지 확인
- 비밀정보 점검
- chat SQLite, Streamlit log, 예외 메시지에서 토큰 원문 검색 결과 0건
## 11. 리스크 & 대안 검토
- **선택**: KB MCP 전용 `kb_vpd_streamable_http` 선언을 도입하고, 로컬 8500용 `custom_python`과 분리한다. 외부 HTTPS/VPD Bearer 계약을 코드와 운영 화면에서 명확히 할 수 있다.
- **대안 1 — 기존 `custom_python` 재사용**: 동작은 시킬 수 있으나 provider 이름과 endpoint 제약이 실제 KB 서버와 맞지 않아 로컬 MCP와 외부 VPD MCP가 섞인다.
- **대안 2 — 고정 MCP access token 사용**: 구현은 간단하지만 모든 사용자가 동일 VPD 주체가 되어 데이터 권한 분리가 무너진다. 채택하지 않는다.
- **대안 3 — 즉시 OAuth 2.1 전환**: 표준 상호운용성에는 유리하지만 현재 데모의 VPD token 발급·검증 체계를 대체하므로 별도 인증 서버 설계가 필요하다.
- 롤백: 새 registry 선언을 비활성화하고 기존 KB 선언을 복원한다. DB VPD 정책·ORDS endpoint·토큰 데이터는 변경하지 않는다.
## 12. 미해결 질문 (Open Questions)
- VPD Backoffice MCP endpoint가 현재 지원할 MCP protocol version을 어떤 값으로 공식 고정할지 결정이 필요하다.
- Streamable HTTP의 GET/SSE 및 `Mcp-Session-Id`를 완전 지원할지, stateless POST profile로 운영할지 결정이 필요하다.
- 데모 이후 사용자 VPD bearer를 OAuth 2.1 access token으로 전환할지, 현 토큰을 resource-server token으로 계속 운영할지 결정이 필요하다.
- chat 대화 이력의 `basis_json`/`details_json`에 민감 데이터 보존 기간과 삭제 정책을 별도로 정해야 한다.

View File

@@ -0,0 +1,83 @@
# VPD·ASO 권한 운영 페이지별 페르소나 리뷰
> 상태: Review only
> 작성일: 2026-07-13
> 범위: 현재 Spring Boot 백오피스 화면을 기준으로, 구현 변경 없이 페이지별 개선 의견만 정리
## 1. 이번 리뷰의 전제
이 백오피스의 핵심 목적은 Oracle DB에서 VPD와 ASO(Data Redaction)를 조합해 사용자별 데이터 접근 범위를 운영하는 것이다.
- VPD는 행을 남기거나 제외한다. 토큰이 직접 VPD 함수에 전달되는 것이 아니라, 토큰 검증 후 세션 컨텍스트(`CB_AGENT_CTX`)에 들어간 값이 VPD 필터의 조건으로 쓰인다.
- ASO는 컬럼 값을 원문 또는 마스킹 값으로 반환한다. ASO 정책은 세션 컨텍스트의 `MR_<column_id>` 같은 값을 보고 원문 표시 여부를 판단한다.
- 백오피스에서 등록하는 접근 규칙은 최종 SQL 그 자체가 아니라, VPD 필터가 읽어 SQL predicate로 바꾸는 매핑 데이터다.
- 일반 운영자는 SQL 함수 구조보다 “이 사용자에게 어떤 테이블/행/컬럼이 어떻게 보이는가”를 먼저 이해해야 한다.
## 2. 리뷰 페르소나
| 페르소나 | 관심사 | 실패로 보는 상황 |
|---|---|---|
| 일반 사용자 | 검증 토큰으로 실제 결과를 확인하고 싶다 | VPD, ASO, ORDS, MCP 용어 때문에 무엇을 눌러야 할지 모름 |
| 운영 관리자 | 사용자·역할·권한·마스킹 설정을 안전하게 바꾸고 싶다 | 설정 변경의 영향 범위와 검증 방법이 분리되어 있음 |
| 적용 담당자 | 업무 규칙을 DB 적용 가능한 설정으로 옮기고 싶다 | “본인계약”, “채널내 전체”, “원문 허용”이 실제 필터/컨텍스트와 어떻게 연결되는지 안 보임 |
| DB 관리자 | DB에 어떤 정책과 스크립트가 적용됐는지 확인하고 싶다 | 백오피스 설정과 DBMS_RLS/DBMS_REDACT 실제 상태가 맞는지 증적이 부족함 |
## 3. 공통 개선 원칙
1. 기본 화면은 “현재 상태, 주요 행동, 검증 버튼” 중심으로 둔다.
2. SQL, 패키지, ORDS, VPD 함수, Redaction expression은 Advanced 또는 도움말로 보낸다.
3. 모든 권한 화면은 `업무 표현 → 저장 설정 → VPD/ASO 해석 → 검증 결과` 순서로 설명한다.
4. VPD와 ASO를 섞어 말하지 않는다.
- VPD: 어떤 행을 볼 수 있는가.
- ASO: 허용된 행의 어떤 컬럼을 원문으로 볼 수 있는가.
5. 권한 설정 화면에서는 “이 조건이 그대로 DB에 붙는다”가 아니라 “이 설정값을 필터가 읽어 WHERE 조건을 만든다”라고 표현한다.
6. 간단한 설정을 기본으로 두고, 조건식·정책명·패키지명·스키마명은 Advanced에서 확인하게 한다.
## 4. 페이지별 리뷰 파일
| 영역 | 페이지 | 리뷰 파일 |
|---|---|---|
| 시작 | 로그인 | [00-login.md](pages/00-login.md) |
| 시작 | 대시보드 | [01-dashboard.md](pages/01-dashboard.md) |
| 권한 주체 | 사용자 | [02-users.md](pages/02-users.md) |
| 권한 주체 | 그룹 | [03-groups.md](pages/03-groups.md) |
| 권한 주체 | 역할 | [04-roles.md](pages/04-roles.md) |
| 권한 설정 | 접근 규칙 | [05-permissions.md](pages/05-permissions.md) |
| 권한 설정 | 원문 조회 허용 사용자 | [06-user-masking-rules.md](pages/06-user-masking-rules.md) |
| 권한 설정 | 사용자별 접근 확인 | [07-effective-matrix.md](pages/07-effective-matrix.md) |
| 보호·검증 | 보호 상태 | [08-vpd-policies.md](pages/08-vpd-policies.md) |
| 보호·검증 | 마스킹 규칙 | [09-masking-rules.md](pages/09-masking-rules.md) |
| 보호·검증 | 검증 세션 | [10-tokens.md](pages/10-tokens.md) |
| 보호·검증 | 접근 검증 | [11-probe.md](pages/11-probe.md) |
| 연동 | 조회 대상 | [12-objects.md](pages/12-objects.md) |
| 연동 | 정형 데이터 조회 | [13-structured-data.md](pages/13-structured-data.md) |
| 연동 | 조회 연동 | [14-ords-handlers.md](pages/14-ords-handlers.md) |
| 연동 | 지식 검색 | [15-vector-knowledge.md](pages/15-vector-knowledge.md) |
| 연동 | 대화형 검색 | [16-mcp-chatbot.md](pages/16-mcp-chatbot.md) |
| 연동 | 검색 해석 | [17-mcp-reasoning.md](pages/17-mcp-reasoning.md) |
| 연동 | MCP 서비스 | [18-mcp-sse.md](pages/18-mcp-sse.md) |
| 연동 | 연동 점검 | [19-mcp-client-demo.md](pages/19-mcp-client-demo.md) |
| 운영 | 운영 현황 | [20-operation-status.md](pages/20-operation-status.md) |
| 관리자 | VPD 필터 구조 | [21-vpd-filter-runtime.md](pages/21-vpd-filter-runtime.md) |
| 관리자 | DB 메타데이터 | [22-schema-metadata.md](pages/22-schema-metadata.md) |
| 관리자 | 보안 SQL 스크립트 | [23-security-sql-scripts.md](pages/23-security-sql-scripts.md) |
| 관리자 | 고급 접근 조건 | [24-vpd-filter-policies.md](pages/24-vpd-filter-policies.md) |
| 관리자 | 시스템 설정 | [25-settings.md](pages/25-settings.md) |
| 관리자 | DB 준비 상태 | [26-settings-database.md](pages/26-settings-database.md) |
## 5. 적용 우선순위 제안
| 우선순위 | 대상 | 이유 |
|---|---|---|
| P0 | 접근 규칙, 마스킹 규칙, 원문 조회 허용 사용자, 접근 검증 | 사용자가 VPD/ASO의 차이와 실제 적용 방식을 가장 많이 혼동하는 지점 |
| P0 | 보호 상태, 운영 현황 | DB 실제 적용 상태와 백오피스 설정 상태를 구분해야 장애 판단이 가능 |
| P1 | 사용자, 그룹, 역할, 사용자별 접근 확인 | 권한 주체와 상속 경로를 업무 담당자가 이해하기 쉽게 해야 함 |
| P1 | DB 메타데이터, 보안 SQL 스크립트, VPD 필터 구조 | 적용 담당자와 DB 관리자의 증적 확인 화면 |
| P2 | MCP/Select AI/Vector 연동 화면 | 기능 자체보다 권한이 적용된 호출 흐름과 지연 원인을 보여주는 방향으로 정리 |
## 6. 구현 전 확인할 설계 판단
- ASO는 컬럼 마스킹만 담당하고, 행 접근은 VPD만 담당한다는 원칙을 화면 문구와 메뉴명에 일관되게 반영한다.
- “원문 조회 허용 사용자”는 현재 사용자 단위 UNMASK 예외 중심이다. 향후 “내 담당 고객은 원문, 타인은 마스킹” 같은 조건부 원문 표시가 필요하면 VPD로 행 범위를 먼저 제한하고 ASO 컨텍스트 계산 방식을 확장해야 한다.
- 지점장 집계 요구는 ASO 마스킹 컬럼에 직접 `SUM`을 걸어 해결한다고 가정하면 안 된다. 집계 전용 trusted path나 별도 검증 가능한 API 설계가 필요하다.
- Select AI용 메타데이터 화면은 자연어 질의 품질에 직접 영향을 주므로, 단순 주석 편집이 아니라 “이 컬럼이 어떤 업무 의미인지”를 사람이 이해하고 보강하는 화면으로 다뤄야 한다.

View File

@@ -0,0 +1,28 @@
# 로그인 페이지 리뷰
- URL: `/login`
- 현재 목적: 백오피스 계정으로 VPD 권한 운영 콘솔에 진입한다.
- VPD/ASO 관련성: 로그인 계정은 백오피스 운영 권한이고, 검증 토큰의 업무 사용자와 다르다.
## 페르소나 의견
- 일반 사용자: 로그인한 계정과 나중에 발급하는 VPD 검증 토큰의 사용자가 같은 것인지 헷갈릴 수 있다.
- 운영 관리자: 관리자 계정과 guest 계정의 차이가 첫 화면에서 보이면 안전하다.
- 적용 담당자: “운영 콘솔 로그인”과 “DB 접근 토큰 검증”이 다른 단계임을 알아야 한다.
- DB 관리자: 이 로그인은 DB 계정 로그인이 아니라 애플리케이션 계정이라는 점이 명확해야 한다.
## 가벼운 개선
1. 로그인 카드 하단에 “이 계정은 백오피스 화면 접근용이며, 실제 VPD 검증은 검증 세션 토큰으로 수행합니다.” 문구를 추가한다.
2. guest 계정은 읽기 전용임을 로그인 후 배너뿐 아니라 로그인 화면 안내에도 짧게 표시한다.
3. 로그인 유지 체크박스는 “이 브라우저에서 유지”처럼 보안 범위를 명확히 쓴다.
## Advanced로 둘 내용
- 세션 쿠키 만료 정책
- 권한별 접근 가능 URL
- guest read-only 서버 정책
## 우선순위
P2. 혼동을 줄이는 문구 개선이 중심이고, 권한 계산 로직에는 영향이 없다.

View File

@@ -0,0 +1,28 @@
# 대시보드 리뷰
- URL: `/`
- 현재 목적: 권한 운영 흐름, 메뉴 안내, 현재 검증 가능한 데이터를 보여준다.
- VPD/ASO 관련성: VPD 행 필터와 ASO 컬럼 마스킹의 전체 업무 흐름을 처음 설명하는 화면이다.
## 페르소나 의견
- 일반 사용자: “권한 관리 → 보호·검증 → 연동” 흐름은 좋지만, VPD와 ASO의 차이는 한 문장으로 먼저 보여야 한다.
- 운영 관리자: 오늘 해야 할 일, 예를 들면 “접근 규칙 수정”, “검증 세션 발급”, “DB 적용 상태 확인”이 바로 보여야 한다.
- 적용 담당자: 업무 규칙이 필터 조건으로 변환되는 구조가 대시보드에서 먼저 잡혀야 한다.
- DB 관리자: 실제 DB 정책 상태와 백오피스 설정 상태가 어디에서 확인되는지 바로 연결되어야 한다.
## 가벼운 개선
1. 상단에 `VPD = 행 제한`, `ASO = 컬럼 마스킹`, `Probe = 실제 결과 검증` 3개 요약 카드를 둔다.
2. “권한 설정값은 VPD 함수가 읽어 WHERE 조건으로 바꿉니다”라는 짧은 설명을 접근 규칙 카드에 붙인다.
3. “현재 DB 적용 상태” 요약을 보호 상태 또는 운영 현황으로 바로 연결한다.
## Advanced로 둘 내용
- VPD 함수 내부 흐름
- ASO Data Redaction expression
- ORDS/MCP 호출 구조
## 우선순위
P1. 홈에서 전체 모델을 정확히 잡으면 다른 화면의 설명량을 줄일 수 있다.

View File

@@ -0,0 +1,28 @@
# 사용자 페이지 리뷰
- URL: `/users`
- 현재 목적: 백오피스 권한 모델의 사용자 생성, 목록 확인, 사용자 역할 부여를 수행한다.
- VPD/ASO 관련성: 사용자는 VPD 필터가 읽는 역할·권한의 출발점이며, ASO 원문 허용 설정의 대상이 된다.
## 페르소나 의견
- 일반 사용자: 여기의 사용자가 실제 보험 설계사/지점장인지, 백오피스 계정인지 구분하기 어렵다.
- 운영 관리자: 사용자 선택 후 “이 사용자가 최종적으로 어떤 역할과 보호 객체를 갖는지”를 바로 보고 싶다.
- 적용 담당자: `KB_STAKEHOLDERS`와 토큰 주체의 관계가 사용자 화면에서 보이지 않으면 업무 사용자 매핑을 놓칠 수 있다.
- DB 관리자: 사용자 추가가 DB 계정 생성이 아니라 애플리케이션 권한 데이터 추가라는 점이 필요하다.
## 가벼운 개선
1. 사용자 상세에 “직접 역할, 그룹 상속 역할, 원문 허용 컬럼, 발급 가능 토큰” 요약을 추가한다.
2. VPD 데모 사용자라면 `KB_STAKEHOLDERS.USER_ID` 매핑 상태를 배지로 표시한다.
3. 역할 부여 후 바로 “사용자별 접근 확인”과 “접근 검증”으로 이동하는 버튼을 둔다.
## Advanced로 둘 내용
- 내부 테이블명 `CB_APP_USER`, `CB_USER_ROLE`
- 그룹 상속 SQL
- DB 계정과 애플리케이션 사용자의 차이 상세
## 우선순위
P1. 권한 주체를 이해해야 접근 규칙과 토큰 검증의 혼동이 줄어든다.

View File

@@ -0,0 +1,27 @@
# 그룹 페이지 리뷰
- URL: `/groups`
- 현재 목적: 그룹 생성, 그룹 사용자 연결, 그룹 역할 연결을 관리한다.
- VPD/ASO 관련성: 그룹 역할은 VPD 필터의 effective role 계산에 포함된다.
## 페르소나 의견
- 일반 사용자: 그룹이 실제 조직 지점인지, 권한 묶음인지 알기 어렵다.
- 운영 관리자: 그룹에 역할을 추가하면 몇 명의 사용자가 영향을 받는지 먼저 봐야 한다.
- 적용 담당자: 지점·채널 같은 업무 조직과 권한 그룹의 차이를 표시해야 오해가 줄어든다.
- DB 관리자: 그룹은 VPD predicate에 직접 들어가는 값이 아니라 역할 상속 경로라는 점이 필요하다.
## 가벼운 개선
1. 그룹 선택 시 영향 사용자 수와 상속 역할 목록을 상단에 고정한다.
2. “그룹은 역할을 묶어 부여하는 운영 단위입니다. 지점/채널 조건은 접근 규칙 또는 stakeholder context에서 평가됩니다.” 문구를 추가한다.
3. 그룹 역할 변경 후 사용자별 접근 확인으로 이동시키는 CTA를 제공한다.
## Advanced로 둘 내용
- `CB_USER_GROUP`, `CB_GROUP_ROLE` 조인 구조
- active group만 effective role에 포함되는 조건
## 우선순위
P1. 운영 관리자가 대량 영향 변경을 안전하게 이해하는 데 필요하다.

View File

@@ -0,0 +1,28 @@
# 역할 페이지 리뷰
- URL: `/roles`
- 현재 목적: 역할 생성과 역할 목록 관리를 수행한다.
- VPD/ASO 관련성: 역할은 접근 규칙과 원문 조회 예외를 연결하는 핵심 단위다.
## 페르소나 의견
- 일반 사용자: 역할 이름만 봐서는 어떤 데이터 범위를 의미하는지 알기 어렵다.
- 운영 관리자: 역할 삭제나 이름 변경 전에 연결된 사용자·그룹·권한 수가 먼저 보여야 한다.
- 적용 담당자: “설계사”, “지점장”, “VPD 관리자” 같은 업무 역할과 DB 정책 적용 결과를 같이 보고 싶다.
- DB 관리자: 역할은 Oracle role이 아니라 백오피스 권한 테이블의 역할이라는 점이 명확해야 한다.
## 가벼운 개선
1. 역할 목록에 연결 사용자 수, 그룹 수, 접근 규칙 수, 원문 허용 규칙 수를 표시한다.
2. 역할 상세에서 “이 역할이 만드는 VPD/ASO 효과”를 업무 문장으로 요약한다.
3. 삭제 버튼은 영향 상세 확인 후 노출한다.
## Advanced로 둘 내용
- 내부 role_id
- 권한 테이블 조인 구조
- Oracle DB role과의 차이
## 우선순위
P1. 접근 규칙의 주체가 역할이므로 사용자 영향도를 쉽게 보여줘야 한다.

View File

@@ -0,0 +1,36 @@
# 접근 규칙 페이지 리뷰
- URL: `/permissions`
- 현재 목적: 역할별 보호 객체 접근 규칙을 등록하고 관리한다.
- VPD/ASO 관련성: 이 화면의 설정은 VPD 필터가 읽어 대상 테이블의 WHERE predicate로 변환한다.
## 페르소나 의견
- 일반 사용자: `CUST_ID = token stakeholder` 같은 축약 표현은 실제 적용 방식을 이해하기 어렵다.
- 운영 관리자: 저장한 조건이 “최종 SQL”인지 “필터가 읽는 설정값”인지 명확해야 한다.
- 적용 담당자: 업무 조건을 고르는 방식이어야 한다. 예: 본인 계약, 내 채널 계약, 전체 허용, 정적 SQL 조건.
- DB 관리자: STATIC_SQL 같은 고급 조건은 검증·차단 규칙과 함께 보여야 한다.
## 가벼운 개선
1. 기본 입력은 업무 조건 선택형으로 둔다.
- 전체 행 허용
- 본인 계약
- 내 채널 계약
- 담당 고객
- 내 채널 고객
2. 각 조건 옆에 “필터 변환 예시”를 접힌 형태로 보여준다.
- 예: 본인 계약 → `FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`
3. 목록에는 저장된 rule_type보다 업무 문장을 먼저 보여준다.
4. STATIC_SQL은 Advanced 영역으로 이동하고, “검증된 컬럼 조건만 허용”을 명시한다.
## Advanced로 둘 내용
- rule_type, rule_column, rule_value 원본
- 생성되는 VPD predicate 예시
- safe_static_predicate 방어 규칙
- ALLOW/DENY 결합 방식
## 우선순위
P0. 사용자가 가장 많이 오해하는 화면이다. “설정값을 필터가 WHERE로 바꾼다”는 표현을 반드시 강화해야 한다.

View File

@@ -0,0 +1,28 @@
# 원문 조회 허용 사용자 페이지 리뷰
- URL: `/user-masking-rules`
- 현재 목적: ASO 마스킹 대상 컬럼에 대해 특정 사용자에게 원문 조회 예외를 준다.
- VPD/ASO 관련성: VPD로 허용된 행 안에서 특정 컬럼을 원문으로 볼 수 있는지 결정한다.
## 페르소나 의견
- 일반 사용자: “원문 허용”이 행 접근 권한까지 주는 것처럼 보일 수 있다.
- 운영 관리자: 특정 사용자에게 컬럼 원문을 허용하면 어떤 테이블/컬럼에 영향이 있는지 바로 봐야 한다.
- 적용 담당자: “자기 고객이면 원문, 타 고객이면 마스킹” 같은 조건부 요구는 현재 단순 UNMASK 예외와 다르다는 점이 필요하다.
- DB 관리자: 이 설정은 ASO expression이 읽는 세션 context를 만드는 입력값이라는 설명이 필요하다.
## 가벼운 개선
1. 상단 문구를 “행 접근은 VPD가 결정하고, 이 화면은 허용된 행의 컬럼 원문 표시만 결정합니다.”로 고정한다.
2. 사용자 선택 시 “이 사용자가 VPD로 볼 수 있는 행 범위” 링크를 접근 검증으로 연결한다.
3. 단순 원문 허용과 조건부 원문 허용의 차이를 안내한다.
## Advanced로 둘 내용
- `set_masking_rule_context(user_id)` 흐름
- `MR_<column_id>` 세션 컨텍스트
- `DBMS_REDACT.UPDATE_POLICY_EXPRESSION` 적용 방식
## 우선순위
P0. VPD와 ASO 권한을 섞어 이해하면 운영 사고로 이어질 수 있다.

View File

@@ -0,0 +1,28 @@
# 사용자별 접근 확인 페이지 리뷰
- URL: `/effective-matrix`
- 현재 목적: 사용자별 최종 역할과 보호 객체 접근 현황을 확인한다.
- VPD/ASO 관련성: VPD가 사용할 effective role과 접근 규칙의 근거를 설명한다.
## 페르소나 의견
- 일반 사용자: 이 화면이 실제 DB 조회 결과인지, 설정상 예상 결과인지 구분이 필요하다.
- 운영 관리자: 사용자 한 명을 선택하면 직접 역할, 그룹 역할, 최종 접근 객체가 한 흐름으로 보여야 한다.
- 적용 담당자: “왜 이 사용자가 이 객체를 볼 수 있는가”의 근거 경로가 필요하다.
- DB 관리자: 실제 DB 정책 적용 여부는 별도 화면이라는 구분이 필요하다.
## 가벼운 개선
1. 상단에 “이 화면은 설정 기반 예상 권한입니다. 실제 조회 결과는 접근 검증에서 확인합니다.”를 표시한다.
2. 사용자별 카드에 `직접 역할 → 그룹 상속 → 최종 역할 → 접근 객체` 타임라인을 둔다.
3. 각 보호 객체에서 `접근 검증`으로 바로 이동하게 한다.
## Advanced로 둘 내용
- effective role SQL
- ALLOW/DENY 결합 로직
- 그룹 active 여부 반영 방식
## 우선순위
P1. 운영자가 변경 전후 영향 확인에 사용하는 중심 화면으로 만들 필요가 있다.

View File

@@ -0,0 +1,28 @@
# 보호 상태 페이지 리뷰
- URL: `/vpd-policies`
- 현재 목적: 보호 대상 객체와 DB에 적용된 VPD 정책 상태를 확인하고 연결한다.
- VPD/ASO 관련성: VPD 함수와 테이블 연결 상태를 DBMS_RLS 기준으로 확인하는 화면이다.
## 페르소나 의견
- 일반 사용자: “보호 적용”이 행 필터만 의미하는지, 컬럼 마스킹도 포함하는지 혼동할 수 있다.
- 운영 관리자: 백오피스 설정은 있는데 DB 정책이 빠진 상태를 쉽게 알아야 한다.
- 적용 담당자: 보호 객체별로 “설정 있음 / DB 적용됨 / 검증 성공” 3단계를 나눠 보고 싶다.
- DB 관리자: policy_name, function_schema, function_name, enable 상태가 증적으로 필요하다.
## 가벼운 개선
1. 배지를 `설정됨`, `DB VPD 적용`, `최근 검증 성공`으로 분리한다.
2. 컬럼 마스킹은 별도 ASO 상태임을 마스킹 규칙 화면으로 연결한다.
3. 보호 연결 버튼 옆에 “연결 후 접근 검증 필요” 문구를 둔다.
## Advanced로 둘 내용
- `DBMS_RLS.ADD_POLICY`/`DROP_POLICY`
- policy function owner
- object schema와 policy schema
## 우선순위
P0. “설정은 했는데 DB에 적용됐는지”를 판단하는 핵심 운영 화면이다.

View File

@@ -0,0 +1,33 @@
# 마스킹 규칙 페이지 리뷰
- URL: `/masking-rules`
- 현재 목적: 사전 정의 마스킹 방식, 마스킹 대상 컬럼, 컬럼별 기본 규칙, DB ASO 정책 동기화를 관리한다.
- VPD/ASO 관련성: ASO Data Redaction 정책과 컬럼 원문/마스킹 판단의 중심 화면이다.
## 페르소나 의견
- 일반 사용자: ASO가 행 접근을 막는 기능으로 오해될 수 있다.
- 운영 관리자: 컬럼 연결을 해제했을 때 DB Redaction 정책도 같이 해제됐는지 확인해야 한다.
- 적용 담당자: 마스킹 방식과 대상 컬럼 등록, 사용자 원문 허용이 서로 어떤 순서인지 알아야 한다.
- DB 관리자: DBMS_REDACT 정책명, 적용 expression, 활성 여부가 필요하다.
## 가벼운 개선
1. 화면 상단에 3단계 흐름을 둔다.
- 대상 컬럼 등록
- 기본 마스킹 방식 연결
- 원문 조회 허용 사용자 지정
2. 각 컬럼 행에 `백오피스 연결 상태``DB ASO 적용 상태`를 따로 표시한다.
3. “VPD로 허용된 행 안에서만 마스킹 여부가 의미 있습니다.” 문구를 반복 노출한다.
4. 숫자 컬럼 마스킹과 집계의 관계는 도움말로 분리한다. `SUM`이 원문 합계를 보장한다고 표현하면 안 된다.
## Advanced로 둘 내용
- Redaction function type
- policy expression
- `MR_<column_id>` context
- DBMS_REDACT 오류 코드와 동기화 로그
## 우선순위
P0. ASO 설정의 실제 DB 적용 여부를 화면에서 바로 판단할 수 있어야 한다.

View File

@@ -0,0 +1,32 @@
# 검증 세션 페이지 리뷰
- URL: `/tokens`
- 현재 목적: 이해관계자 기준 Bearer token을 발급하고 발급 이력을 확인한다.
- VPD/ASO 관련성: 토큰은 세션 context를 만들기 위한 입력이고, VPD/ASO는 그 context를 읽는다.
## 페르소나 의견
- 일반 사용자: 발급된 토큰이 어디에 쓰이는지 한 번 더 안내가 필요하다.
- 운영 관리자: 어떤 사용자/이해관계자/역할로 토큰이 발급됐는지 확인해야 한다.
- 적용 담당자: stakeholder의 role, channel, user_id가 context로 어떻게 들어가는지 알아야 한다.
- DB 관리자: 토큰 원문 보관 여부와 만료 정책이 중요하다.
## 가벼운 개선
1. 토큰 발급 결과에 “이 토큰으로 세팅되는 context” 요약을 표시한다.
- `USER_ID`
- `STAKEHOLDER_USER_ID`
- `STAKEHOLDER_ROLE`
- `STAKEHOLDER_CHANNEL`
2. 발급 직후 접근 검증으로 넘길 때 토큰을 자동 선택한다.
3. 토큰 오류 시 “권한이 없거나 만료된 토큰”처럼 사용자 행동 기준 오류를 표시한다.
## Advanced로 둘 내용
- bearer key 저장 방식
- context package 내부 함수
- 만료/폐기 SQL
## 우선순위
P0. 토큰이 권한 자체가 아니라 context 설정 입력이라는 점을 명확히 해야 한다.

View File

@@ -0,0 +1,29 @@
# 접근 검증 페이지 리뷰
- URL: `/probe`
- 현재 목적: 토큰과 보호 객체를 사용해 실제 ORDS/DB 조회 결과를 확인한다.
- VPD/ASO 관련성: VPD 행 필터와 ASO 컬럼 마스킹이 실제 결과에 어떻게 반영됐는지 확인하는 최종 검증 화면이다.
## 페르소나 의견
- 일반 사용자: “내가 이 사용자라면 실제로 무엇을 볼 수 있나”만 빠르게 보고 싶다.
- 운영 관리자: 설정 변경 후 검증 결과와 이전 결과를 비교하고 싶다.
- 적용 담당자: 결과에 VPD predicate와 ASO 마스킹 여부가 같이 보이면 원인 파악이 쉽다.
- DB 관리자: 재현 SQL, 실행 컨텍스트, 적용 정책 증적이 필요하다.
## 가벼운 개선
1. 결과를 `세션 context`, `VPD 행 결과`, `ASO 컬럼 마스킹`, `원본 응답` 4개 탭으로 나눈다.
2. 잘못된 토큰은 DB 오류처럼 보이지 않게 “토큰 권한 없음/만료/인식 불가”로 반환한다.
3. 마스킹된 컬럼은 결과 테이블에서 별도 아이콘이나 툴팁으로 표시한다.
## Advanced로 둘 내용
- SQL trace
- VPD predicate
- ORDS handler source
- DB cursor 증적
## 우선순위
P0. 이 화면은 모든 설정 변경의 성공 기준이다.

View File

@@ -0,0 +1,28 @@
# 조회 대상 페이지 리뷰
- URL: `/objects`
- 현재 목적: ORDS 조회 대상과 보호 객체 후보를 등록하고 관리한다.
- VPD/ASO 관련성: 보호 객체로 등록된 테이블이 VPD/ASO 정책 적용과 검증의 단위가 된다.
## 페르소나 의견
- 일반 사용자: 조회 대상, 보호 대상, ORDS handler의 차이를 구분하기 어렵다.
- 운영 관리자: 새 테이블을 등록하면 다음에 무엇을 해야 하는지 안내가 필요하다.
- 적용 담당자: 업무명, 테이블명, 키 컬럼, 권한 조건 후보를 같이 관리해야 한다.
- DB 관리자: 스키마와 객체명 검증, 실제 존재 여부, 권한 여부가 필요하다.
## 가벼운 개선
1. 객체 등록 후 다음 행동을 `보호 연결`, `접근 규칙 추가`, `접근 검증`으로 안내한다.
2. 목록에 업무명과 DB 객체명을 같이 표시하되, 업무명을 먼저 보여준다.
3. ASO 컬럼 대상 등록은 마스킹 규칙 화면으로 명확히 연결한다.
## Advanced로 둘 내용
- ORDS module/template/handler 연결
- schema owner
- 테이블 존재 확인 SQL
## 우선순위
P1. 신규 테이블 온보딩 흐름을 단순하게 만드는 것이 핵심이다.

View File

@@ -0,0 +1,31 @@
# 정형 데이터 조회 페이지 리뷰
- URL: `/structured-data`
- 현재 목적: KB 원장성 테이블을 관리자용으로 미리 조회한다.
- VPD/ASO 관련성: 실제 사용자별 적용 결과가 아니라 관리자용 원장 미리보기라는 점이 중요하다.
## 페르소나 의견
- 일반 사용자: 여기 결과가 본인 토큰 기준인지 관리자 원본 기준인지 헷갈릴 수 있다.
- 운영 관리자: 데모 데이터 구조를 확인하는 용도로는 좋지만, 권한 검증과 분리되어야 한다.
- 적용 담당자: 각 테이블의 업무 의미, 주요 조인 키, VPD 조건 후보가 같이 보여야 한다.
- DB 관리자: 원장 데이터 조회가 마스킹 정책을 우회하는 관리자 조회인지 표시가 필요하다.
## 가벼운 개선
1. 상단에 “관리자용 데이터 미리보기이며, 사용자별 결과는 접근 검증에서 확인합니다.”를 더 강하게 표시한다.
2. 각 테이블에 권한 기준 컬럼을 표시한다.
- 고객: `CUST_ID`
- 계약: `FC_ID`, `FC_CHANNEL`, `CUST_ID`
- 보상/외부보유: `CUST_ID`
3. 접근 검증으로 바로 이동하는 버튼을 둔다.
## Advanced로 둘 내용
- 전체 컬럼 목록
- 샘플 SQL
- 테이블 조인 구조
## 우선순위
P1. 관리자 미리보기와 사용자별 VPD 결과의 차이를 분명히 해야 한다.

View File

@@ -0,0 +1,29 @@
# 조회 연동 페이지 리뷰
- URL: `/ords-handlers`
- 현재 목적: ORDS handler와 source를 확인한다.
- VPD/ASO 관련성: 외부 호출이 어떤 DB 세션에서 context를 세팅하고 조회하는지 확인하는 기술 화면이다.
## 페르소나 의견
- 일반 사용자: ORDS handler source는 기본 화면에서 볼 필요가 거의 없다.
- 운영 관리자: endpoint 상태와 보호 객체 연결 여부가 먼저 필요하다.
- 적용 담당자: handler가 토큰을 받아 context를 세팅한 뒤 조회하는 흐름이 중요하다.
- DB 관리자: handler source와 DB package, 정책 적용 대상이 증적으로 필요하다.
## 가벼운 개선
1. 기본 화면은 endpoint, method, 보호 객체, 최근 검증 상태만 보여준다.
2. source와 수정 기능은 Advanced 상세로 접는다.
3. handler가 VPD/ASO를 우회하지 않는 이유를 짧게 표시한다.
## Advanced로 둘 내용
- ORDS source
- `set_vpd_context` 호출
- SQL trace
- handler 재배포 절차
## 우선순위
P1. 운영 화면과 개발자 화면의 밀도를 분리해야 한다.

View File

@@ -0,0 +1,28 @@
# 지식 검색 페이지 리뷰
- URL: `/vector-knowledge`
- 현재 목적: 지식자료 등록, 접근 정책 설정, 권한 기반 검색을 수행한다.
- VPD/ASO 관련성: 정형 VPD/ASO와 달리 벡터 지식 검색은 문서 단위 접근 정책과 RAG 검색 품질을 다룬다.
## 페르소나 의견
- 일반 사용자: 정형 데이터 권한과 벡터 검색 권한이 같은 방식인지 헷갈릴 수 있다.
- 운영 관리자: 자료 등록, 접근 정책, 검색 검증이 한 화면에 섞이면 운영 순서가 흐려진다.
- 적용 담당자: 상품/회사/약관 메타데이터가 부족하면 RAG 결과가 경쟁사 근거로 치우칠 수 있다.
- DB 관리자: VPD/ASO가 직접 적용되는 테이블 조회와 벡터 검색 정책을 구분해야 한다.
## 가벼운 개선
1. `자료 등록`, `접근 정책`, `검색 검증`을 탭 또는 단계로 분리한다.
2. 검색 결과에 “정형 MCP 결과 없음 / 벡터 근거만 있음” 같은 출처 구분을 표시한다.
3. 상품 필터와 문서 메타데이터 품질 점검 링크를 DB 메타데이터 화면과 연결한다.
## Advanced로 둘 내용
- embedding 상태
- hybrid rerank 파라미터
- vector table/source table 구조
## 우선순위
P2. VPD/ASO 핵심 화면보다 후순위지만, MCP 질의 품질에는 중요하다.

View File

@@ -0,0 +1,29 @@
# 대화형 검색 페이지 리뷰
- URL: `/mcp-chatbot`
- 현재 목적: 자연어 질의로 MCP/Select AI/Vector 검색을 호출한다.
- VPD/ASO 관련성: 자연어 질의라도 정형 DB 호출에는 VPD/ASO context가 적용되어야 한다.
## 페르소나 의견
- 일반 사용자: 질문 입력과 답변이 중심이어야 하고, 도구명은 보조 정보여야 한다.
- 운영 관리자: 어떤 토큰으로 어떤 도구가 호출됐는지 알 수 있어야 한다.
- 적용 담당자: Select AI가 잘못된 SQL을 만들면 테이블/컬럼 comment 보강으로 이어져야 한다.
- DB 관리자: DB 오류, 모델 지연, VPD 차단, ASO 마스킹을 구분해야 한다.
## 가벼운 개선
1. 결과를 `답변`, `호출 도구`, `정형 데이터 결과`, `근거 문서`, `오류 원인`으로 분리한다.
2. Select AI 호출 시간이 길면 모델/profile/재시도 여부를 표시한다.
3. “권한 때문에 안 보임”과 “질의 생성 실패”를 다른 오류로 보여준다.
## Advanced로 둘 내용
- MCP tool JSON
- Select AI showprompt/showsql
- LLM profile
- raw ORDS response
## 우선순위
P2. 데모 품질에는 중요하지만, 먼저 권한 운영 화면을 정리한 뒤 다루는 것이 좋다.

View File

@@ -0,0 +1,29 @@
# 검색 해석 페이지 리뷰
- URL: `/mcp-reasoning`
- 현재 목적: MCP 도구 선택과 권한 결과 해석을 확인한다.
- VPD/ASO 관련성: 자연어 질의가 어떤 protected tool로 라우팅되고, 어떤 토큰으로 실행됐는지 설명한다.
## 페르소나 의견
- 일반 사용자: “왜 이 도구가 선택됐는가”를 업무 문장으로 알고 싶다.
- 운영 관리자: 실패 시 정형 MCP, 벡터 검색, Select AI 중 어느 구간이 문제인지 봐야 한다.
- 적용 담당자: 라우팅 결과와 스키마 메타데이터 부족을 연결해야 한다.
- DB 관리자: 실제 DB 호출과 VPD/ASO 적용 여부를 증적으로 보고 싶다.
## 가벼운 개선
1. 결과 타임라인을 `질문 → 도구 선택 → 토큰 context → DB/RAG 호출 → 응답` 순서로 표시한다.
2. 각 단계의 시간과 오류 원인을 표시한다.
3. Select AI prompt 또는 generated SQL은 Advanced에서 열람한다.
## Advanced로 둘 내용
- routing payload
- tool allowlist
- generated SQL
- raw trace
## 우선순위
P2. MCP 진단용 화면으로서 기본 사용자보다 적용 담당자 중심으로 정리한다.

View File

@@ -0,0 +1,28 @@
# MCP 서비스 페이지 리뷰
- URL: `/mcp-sse`
- 현재 목적: MCP endpoint와 JSON-RPC 호출 정보를 제공한다.
- VPD/ASO 관련성: MCP 외부 클라이언트가 VPD 적용 Select AI tool을 호출하는 진입점이다.
## 페르소나 의견
- 일반 사용자: 프로토콜 설명보다 “어디에 등록하면 되는가”가 먼저 필요하다.
- 운영 관리자: endpoint, 인증 방식, 허용 tool, 상태를 한눈에 봐야 한다.
- 적용 담당자: Bearer token을 MCP 서버 인증과 업무 사용자 token으로 나누면 혼동된다. 가능하면 업무 토큰 하나로 설명해야 한다.
- DB 관리자: MCP 호출이 ORDS와 DB context 세팅을 거치는지 확인해야 한다.
## 가벼운 개선
1. 상단에 복사 가능한 client 설정 명세를 제공한다.
2. 인증 헤더는 실제 설계 기준으로 하나만 설명한다. 이중 토큰이 필요하면 이유를 명확히 쓴다.
3. 허용 tool이 하나라면 tool allowlist를 단순하게 표시한다.
## Advanced로 둘 내용
- JSON-RPC 예시
- streaming/http transport 차이
- timeout/retry 정책
## 우선순위
P2. 외부 연동 개발자에게 필요한 문서형 화면으로 정리한다.

View File

@@ -0,0 +1,29 @@
# 연동 점검 페이지 리뷰
- URL: `/mcp-client-demo`
- 현재 목적: Java client로 MCP 호출을 점검한다.
- VPD/ASO 관련성: MCP를 통해 호출한 정형 DB 결과에도 VPD/ASO가 적용되는지 확인한다.
## 페르소나 의견
- 일반 사용자: Java client 호출 정보는 과하다.
- 운영 관리자: 현재 endpoint가 호출 가능한지, 권한 오류인지, timeout인지 알고 싶다.
- 적용 담당자: client 설정과 서버 tool 명세가 일치하는지 확인해야 한다.
- DB 관리자: 호출이 어떤 DB profile과 ORDS endpoint를 쓰는지 추적하고 싶다.
## 가벼운 개선
1. 기본은 `연결 가능`, `도구 호출 가능`, `VPD 적용 결과 수신` 3개 상태로 표시한다.
2. curl 예시와 MCP client 설정 예시를 복사 버튼으로 제공한다.
3. Java stack/detail은 Advanced로 보낸다.
## Advanced로 둘 내용
- Java client raw request/response
- timeout 설정
- retry 횟수
- tool schema
## 우선순위
P2. 연동 개발자용 진단 화면이다.

View File

@@ -0,0 +1,29 @@
# 운영 현황 페이지 리뷰
- URL: `/operation-status`
- 현재 목적: 백오피스와 DB/ORDS/MCP 관련 운영 상태를 확인한다.
- VPD/ASO 관련성: 설정 상태와 DB 실제 적용 상태, 최근 동기화 결과를 구분해야 한다.
## 페르소나 의견
- 일반 사용자: 정상/주의/장애만 먼저 보고 싶다.
- 운영 관리자: 장애가 어느 기능에 영향을 주는지 알아야 한다.
- 적용 담당자: ASO 동기화 실패, VPD 정책 누락, ORDS 오류를 구분해야 한다.
- DB 관리자: DB 조회 기반 상태와 애플리케이션 설정 기반 상태를 분리해서 봐야 한다.
## 가벼운 개선
1. 상단에 전체 상태 배너를 둔다.
2. 상태 항목을 `앱`, `DB 연결`, `VPD 정책`, `ASO 정책`, `ORDS`, `MCP/Select AI`로 나눈다.
3. 각 항목에 최근 확인 시각, 영향 범위, 권장 조치를 표시한다.
## Advanced로 둘 내용
- raw health response
- SQL check query
- systemd/log 위치
- DB 오류 전문
## 우선순위
P0. 운영자가 “지금 정상인가”를 판단하는 중심 화면이다.

View File

@@ -0,0 +1,29 @@
# VPD 필터 구조 페이지 리뷰
- URL: `/vpd-filter-runtime`
- 현재 목적: `CB_AGENT_DOC_VPD_FILTER`의 연결 상태와 배포된 함수 소스를 읽기 전용으로 보여준다.
- VPD/ASO 관련성: VPD 행 필터가 권한 설정을 실제 WHERE predicate로 바꾸는 핵심 구조를 설명한다.
## 페르소나 의견
- 일반 사용자: 함수 소스는 기본적으로 너무 어렵다.
- 운영 관리자: 이 화면은 수정 화면이 아니라 근거 확인 화면이라는 점이 필요하다.
- 적용 담당자: 토큰이 context로 바뀌고, 필터가 context와 권한 테이블을 읽는 흐름을 이해해야 한다.
- DB 관리자: 실제 DB 함수 소스와 Git source가 일치하는지 확인하고 싶다.
## 가벼운 개선
1. 상단 도움말에 “토큰 → context → 권한 테이블 → VPD predicate → 결과 행” 흐름을 유지한다.
2. 함수 소스는 기본 접힘으로 두고, 주요 블록별 설명을 먼저 보여준다.
3. ASO와의 차이 표를 유지하되 “VPD는 컬럼 값을 NULL 처리하지 않는다”를 명시한다.
## Advanced로 둘 내용
- 전체 PL/SQL source
- safe column/static SQL 검증
- ALLOW/DENY predicate 조립
- policy binding metadata
## 우선순위
P1. 적용 담당자와 DB 관리자의 신뢰 확보용 화면이다.

View File

@@ -0,0 +1,29 @@
# DB 메타데이터 페이지 리뷰
- URL: `/schema-metadata`
- 현재 목적: 테이블/컬럼 comment와 annotation을 확인하고 수정한다.
- VPD/ASO 관련성: Select AI가 자연어 질의를 SQL로 바꿀 때 테이블·컬럼 의미를 이해하게 돕는 화면이다.
## 페르소나 의견
- 일반 사용자: comment와 annotation의 차이를 알기 어렵다.
- 운영 관리자: 자연어 질의 실패 원인이 메타데이터 부족일 수 있음을 알아야 한다.
- 적용 담당자: 업무 용어, 조인 키, 권한 기준 컬럼을 명확히 입력해야 한다.
- DB 관리자: 실제 DB comment와 백오피스 annotation 저장소가 어떻게 동기화되는지 봐야 한다.
## 가벼운 개선
1. 각 컬럼에 `업무 의미`, `권한 기준`, `Select AI 힌트`를 나눠 표시한다.
2. `CUST_ID`, `CONTRACT_NO`, `FC_ID`, `FC_CHANNEL` 같은 권한 기준 컬럼은 배지로 강조한다.
3. 자연어 질의 오류 리포트에서 이 화면으로 연결한다.
## Advanced로 둘 내용
- DB comment DDL
- annotation storage schema
- Select AI showprompt
- generated SQL 비교
## 우선순위
P1. 자연어 기반 Select AI 품질 개선의 핵심 운영 화면이다.

View File

@@ -0,0 +1,29 @@
# 보안 SQL 스크립트 페이지 리뷰
- URL: `/security-sql-scripts`
- 현재 목적: Git에 저장된 VPD/ASO/ORDS/Select AI 관련 SQL 스크립트를 확인하고 LLM 설명을 생성한다.
- VPD/ASO 관련성: 운영자가 실제 DB 적용 스크립트를 이해하고 검토하는 증적 화면이다.
## 페르소나 의견
- 일반 사용자: 전체 SQL보다 “이 스크립트가 뭘 적용하는가”가 먼저 필요하다.
- 운영 관리자: 실행 대상, 영향 범위, 되돌리기 가능 여부가 중요하다.
- 적용 담당자: 토큰/context/VPD/ASO 흐름을 스크립트 단위로 설명해야 한다.
- DB 관리자: 원본 SQL, 주석, LLM 설명, DB 적용 상태를 나란히 보고 싶다.
## 가벼운 개선
1. 스크립트마다 `목적`, `적용 객체`, `변경되는 DB 정책`, `검증 방법`을 상단 카드로 요약한다.
2. LLM 설명은 “토큰이 어떻게 context가 되고, VPD/ASO가 무엇을 읽는가”를 반드시 포함하게 한다.
3. 원본 SQL은 그대로 보여주되, 비전문가 설명은 먼저 제공한다.
## Advanced로 둘 내용
- 전체 SQL source
- script diff
- DB 배포 이력
- LLM prompt 전문
## 우선순위
P1. 복잡한 스크립트를 이해하기 위한 설명 화면으로 방향이 맞다.

View File

@@ -0,0 +1,29 @@
# 고급 접근 조건 페이지 리뷰
- URL: `/vpd-filter-policies`
- 현재 목적: 기본 접근 규칙으로 처리하기 어려운 고급 필터 정책을 관리한다.
- VPD/ASO 관련성: VPD predicate 생성에 영향을 주는 예외적 고급 조건이다.
## 페르소나 의견
- 일반 사용자: 이 화면은 기본 사용자가 접근할 이유가 없다.
- 운영 관리자: 대부분의 변경은 접근 규칙에서 끝난다는 안내가 현재 방향과 맞다.
- 적용 담당자: 고급 조건을 쓰기 전 업무 조건으로 해결 가능한지 판단해야 한다.
- DB 관리자: 임의 SQL 조건은 injection 방어와 검증 결과가 필요하다.
## 가벼운 개선
1. 기본 화면은 “접근 규칙으로 처리 가능한가?” 체크리스트부터 보여준다.
2. 고급 조건 작성은 Advanced로 접고, 저장 전 검증 결과를 필수로 표시한다.
3. 실제 predicate 예시와 실패 시 fail-closed 동작을 설명한다.
## Advanced로 둘 내용
- raw predicate
- validation rules
- 대상 컬럼 whitelist
- DBMS_ASSERT 처리
## 우선순위
P1. 잘못 쓰면 접근 범위를 넓힐 수 있으므로 일반 설정과 분리해야 한다.

View File

@@ -0,0 +1,32 @@
# 시스템 설정 페이지 리뷰
- URL: `/settings`
- 현재 목적: ORDS Base URL 등 시스템 연결 설정을 관리한다.
- VPD/ASO 관련성: 권한 검증과 MCP/ORDS 호출의 기반 연결 정보다.
## 페르소나 의견
- 일반 사용자: 일반 사용자가 자주 볼 화면은 아니다.
- 운영 관리자: 연결 URL 변경이 어떤 기능에 영향을 주는지 알아야 한다.
- 적용 담당자: DB 준비 상태와 ORDS 연결 설정을 구분해야 한다.
- DB 관리자: 운영 설정 변경 이력과 검증 결과가 필요하다.
## 가벼운 개선
1. 변경 가능한 설정과 읽기 전용 환경 설정을 분리한다.
2. 설정 변경 후 영향을 받는 기능을 표시한다.
- 접근 검증
- MCP 호출
- ORDS handler 조회
3. 저장 후 자동 health check를 실행하고 결과를 보여준다.
## Advanced로 둘 내용
- 환경 변수명
- systemd env 파일
- connection timeout
- proxy/TLS 설정
## 우선순위
P2. 기능 안정성에는 중요하지만 일반 권한 흐름보다는 후순위다.

View File

@@ -0,0 +1,29 @@
# DB 준비 상태 페이지 리뷰
- URL: `/settings/database`
- 현재 목적: 지원 테이블과 VPD runtime 준비 상태를 확인하고 초기 구성 SQL을 실행한다.
- VPD/ASO 관련성: 권한 운영에 필요한 메타 테이블, context package, policy function, ASO metadata의 준비 상태를 다룬다.
## 페르소나 의견
- 일반 사용자: 거의 볼 필요가 없는 관리자 화면이다.
- 운영 관리자: 운영 중 재실행하면 위험한 작업과 안전한 확인 작업을 구분해야 한다.
- 적용 담당자: 어떤 단계가 누락되면 어떤 화면이 실패하는지 알고 싶다.
- DB 관리자: 실행 전 SQL, 실행 권한, 생성 객체, 재실행 안전성이 필요하다.
## 가벼운 개선
1. 상태 확인과 변경 실행을 완전히 분리한다.
2. 변경 실행 전에는 생성/수정/건너뜀/위험 항목을 요약한다.
3. VPD와 ASO 준비 항목을 별도 섹션으로 표시한다.
## Advanced로 둘 내용
- 전체 DDL
- package/function source
- DB 권한 grant
- 재실행 idempotency 설명
## 우선순위
P1. DB 준비 화면은 강력한 변경 화면이므로 안전 장치와 설명이 필요하다.

View File

@@ -0,0 +1,51 @@
<!-- 함수별 설계서 템플릿. 복잡 함수마다 design/<issue-id>-<slug>/fn-<function_name>.md 로 작성.
작성: [AI] Architect, 구현 전 필수. -->
# 함수 설계서: `<function_name>` (#<issue-id>)
> **부모 설계서**: ./README.md · **상태**: Draft <!-- Draft|Approved|Superseded -->
> **작성**: [AI] Architect · **구현**: <file:function 또는 TBD> · **테스트**: <경로 또는 TBD>
## 1. 시그니처
```
<returnType> <function_name>(<params>) # 언어 확정 후 정확히 기재
```
## 2. 책임 (단일 책임, 1줄)
이 함수가 하는 단 하나의 일.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `<p>` | | | |
## 4. 출력
- **반환**: 타입 / 의미.
- **부수효과**: (있으면 — I/O·상태변경 명시) / 없으면 **순수 함수**.
## 5. 동작 / 알고리즘
1. ...
2. ...
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| | | |
## 7. 엣지케이스
- 경계값(0, 음수, 빈값, 최대), 동시성, 부분 실패.
## 8. 복잡도 / 성능
- 시간/공간 복잡도. 호출 빈도(예: 시세 폴링 루프 내부인가?).
## 9. 의존성
- 호출하는 함수/모듈, 외부 API, 설정 키.
## 10. 테스트 케이스
- [ ] 정상: <입력 → 기대 출력>
- [ ] 경계: ...
- [ ] 실패: ...
## 11. 추적성
- 인수조건: #<issue-id> 의 "<항목>".
- 관련 ADR: <ADR-NNNN 또는 없음>.

66
docs/design/_TEMPLATE.md Normal file
View File

@@ -0,0 +1,66 @@
<!-- 기능 설계서 템플릿. 복사해서 design/<issue-id>-<slug>/README.md 로 작성.
작성: [AI] Architect, 구현 전 필수. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: <기능명> (#<issue-id>)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: <YYYY-MM-DD>
> **추적성** — Redmine: #<issue-id> · 관련 ADR: <ADR-NNNN 또는 없음>
> · 구현 파일: <경로 또는 TBD> · 테스트: <경로 또는 TBD>
## 1. 목적 (Why)
이 기능이 푸는 문제. Planner 의 목표 1줄 인용.
## 2. 범위 (Scope)
- **포함**: ...
- **제외 (out of scope)**: ...
## 3. 인수조건 (Acceptance Criteria)
<!-- Planner 가 확정한 검증 가능한 항목. QA 가 이걸로 판정한다. -->
- [ ] ...
- [ ] ...
## 4. 컨텍스트 & 제약
- 의존성: 거래소 API / DB / 알림 / 외부 라이브러리.
- 제약: 성능, 레이트리밋, 리스크(돈), 보안.
- 가정: ...
## 5. 아키텍처 개요
- 모듈/파일 구조 (목록).
- 데이터 흐름 (텍스트 다이어그램).
- **I/O ↔ 순수 전략 로직 경계** 명시 (테스트 가능성).
```
<여기에 ASCII 흐름도>
```
## 6. 데이터 모델
- 입력 / 출력 / 저장 구조, 타입, **경계 검증 규칙**.
## 7. 함수 명세 (Function Specs)
<!-- 모든 함수를 나열. "복잡?" = 복잡이면 fn-<name>.md 개별 설계서 필수. -->
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `<name>` | | | | | | 단순 / **복잡** |
> 복잡 기준: 분기/상태기계, 외부 I/O, 리스크(주문·잔고) 경로, 비자명 알고리즘.
> → 해당 함수는 `fn-<name>.md` 작성. 단순(게터·포매터 등)은 이 표로 충분.
## 8. 흐름 / 알고리즘
- 핵심 시나리오 단계별. 상태 전이.
## 9. 엣지케이스 & 에러 처리
- 경계값, 실패 모드, 재시도/백오프.
- **안전한 기본값**(API 실패 시 거래 중단 등).
## 10. 테스트 계획
- 단위/통합 케이스 목록 (각 인수조건에 매핑).
- 모킹/드라이런 전략 (거래소 API 등).
## 11. 리스크 & 대안 검토
- 선택한 접근 vs 대안, 트레이드오프.
- 되돌리기 어려운 결정 → **ADR 로 분리** (`adr/NNNN-*.md`).
## 12. 미해결 질문 (Open Questions)
- ...

BIN
docs/ords_vpd_dds.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,42 @@
# Queue Protocol — 모든 페르소나 공통 규약
작업 큐 = Redmine 이슈. 각 페르소나는 자기 단계 이슈를 처리하고 git/Redmine 에 남긴 뒤 다음으로 넘긴다.
## 0. 환경 로드
```bash
set -a; . ./.env; set +a
RK="$REDMINE_API_KEY"; RB="$REDMINE_URL"; PROJ="$REDMINE_PROJECT"
# 카테고리 id 는 이름으로 조회(프로젝트마다 id 다름):
catid(){ curl -s -H "X-Redmine-API-Key: $RK" "$RB/projects/$PROJ/issue_categories.json" \
| python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='$1']"; }
```
## 1. 큐 매핑
- 현재 단계 = 카테고리 `01-Planner``08-Documenter`,`09-Done`.
- 수명주기 = 상태 신규(대기)/진행/완료/거절.
## 2. 내 작업 꺼내기
```bash
DEV=$(catid 03-Developer)
curl -s -H "X-Redmine-API-Key: $RK" "$RB/issues.json?project_id=$PROJ&category_id=$DEV&status_id=1&sort=id:asc&limit=1"
# 시작 시 상태 진행(2):
curl -s -H "X-Redmine-API-Key: $RK" -H "Content-Type: application/json" -X PUT "$RB/issues/<ID>.json" -d '{"issue":{"status_id":2}}'
```
## 3~4. 결과 남기기 (필수 3가지)
- (a) git 커밋+push (`[<Persona>] #<ID> ...`)
- (b) Redmine 저널 노트(역할 태그)
- (c) 다음 단계 전진: 카테고리=다음이름의 id, 상태 신규(1)
```bash
NEXT=$(catid 04-QA)
curl -s -H "X-Redmine-API-Key: $RK" -H "Content-Type: application/json" -X PUT "$RB/issues/<ID>.json" \
-d "{\"issue\":{\"category_id\":$NEXT,\"status_id\":1,\"notes\":\"[<Persona>] ...\"}}"
```
## 5. 게이트 반려
- QA(04)/Reviewer(06) 실패 → `03-Developer`. Developer 설계서 누락 → `02-Architect`. 사유를 노트에.
## 6. 종료 (Documenter)
- `09-Done` + 상태 완료(5) + done_ratio 100.
원칙: 자기 역할 범위만, 모든 변경 git 추적, 비밀(.env) 노출 금지.

View File

@@ -0,0 +1,108 @@
# 기존-01 MCP/RAG 진단 리포트
## 대상 시나리오
- 질문 번호: 기존-01
- 분류: 권한 + RAG 질문형
- 이해관계자: 설계사 `FC00789`
- 질의: `C1001006 고객 자동차보험 갱신 상담 전에, 현재 KB 계약(41048)과 삼성화재 보유 자동차보험 약관을 비교해서 고객에게 설명할 차별 포인트를 정리해줘.`
## 정형 MCP 확인 결과
정형 MCP 경로는 복구 확인됐다.
- MCP endpoint: `https://kb.cloud-handson.com/mcp`
- tool: `ords.query.kb_select_ai_vpd`
- Select AI profile: `KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2`
- 검증 결과:
- `HTTP_STATUS=200`
- `MCP_HAS_ERROR=FALSE`
- `MCP_IS_ERROR=FALSE`
- `ITEM_COUNT=1`
생성 SQL은 `41048``CONTRACT_NO`가 아니라 `PRODUCT_CD`로 해석했다.
```text
KC.CUST_ID = 'C1001006'
KC.PRODUCT_CD = '41048'
EH.EXT_INSURER = '삼성화재'
EH.EXT_PRODUCT_GRP = '자동차'
```
대표 반환값:
```text
CUSTOMER_ID=C1001006
KB_CONTRACT_NO=CT2699001
KB_PRODUCT_CODE=41048
KB_PRODUCT_NAME=41048_KB개인용자동차보험
KB_PREMIUM=1350000
EXTERNAL_INSURER=삼성화재
EXTERNAL_PRODUCT_GROUP=자동차
EXTERNAL_PRODUCT_TYPE=개인용
EXTERNAL_CLAUSE_NAME=개인용애니카다이렉트자동차보험
```
## 적용한 정형 MCP 보완
- `POC_2` KB 업무 테이블/컬럼 comment를 보강했다.
- Select AI `showprompt` 확인 결과, comment와 annotation이 실제 모델 프롬프트에 포함됨을 확인했다.
- SQL 정규화/검증 로직을 보완했다.
- 마크다운 fence와 선행 wrapper comment를 제거한다.
- 문자열 리터럴 내부의 세미콜론/금지어를 실행 문법으로 오판하지 않도록 검사한다.
- DDL/DML/PLSQL/system object 차단은 유지한다.
변경 스크립트:
- `sql/adb/65_kb_select_ai_vpd_query_api.sql`
## RAG 근거 검색 확인 결과
RAG corpus는 `POC_2` 스키마에 존재한다.
```text
POC_2.KB_OWN_TERMS_DOCUMENTS
POC_2.KB_OWN_TERMS_CHUNKS
POC_2.KB_COMPETITOR_TERMS_DOCUMENTS
POC_2.KB_COMPETITOR_TERMS_CHUNKS
POC_2.KB_TERMS_CHUNKS_ALL_V
```
건수:
```text
TOTAL_CHUNKS=38772
OWN_CHUNKS=28506
COMP_CHUNKS=10266
```
KB 41048 약관 근거는 존재한다.
```text
DOC_41048|KB손해보험|OWN||KB개인용자동차보험|b5b680222361c4cde7a54855925333a3
SAMPLE_41048|KB손해보험|OWN||KB개인용자동차보험|...|자동차26-41048-1-04 KB개인용자동차보험 ...
```
## RAG 측 원인 판단
KB 41048 약관 chunk는 있지만 `PRODUCT_CODE` 메타데이터가 비어 있다.
```text
company_name=KB손해보험
company_type=OWN
product_code=<empty>
product_name=KB개인용자동차보험
chunk_text contains 자동차26-41048-1-04
```
따라서 RAG 검색/필터가 `PRODUCT_CODE = '41048'` 또는 상품코드 기반 필터를 사용하면 KB 근거가 제외될 수 있다. 본문 텍스트에는 `41048`이 있으므로 순수 텍스트 검색으로는 찾을 수 있지만, 메타데이터 필터 기준 검색에서는 빠질 가능성이 높다.
## 권고
RAG/약관 적재 담당 영역에서 아래 중 하나를 처리해야 한다.
1. `KB_OWN_TERMS_DOCUMENTS` / `KB_OWN_TERMS_CHUNKS` 적재 시 `자동차26-41048-1-04`에서 업무 상품코드 `41048`을 추출해 `PRODUCT_CODE`에 저장한다.
2. 기존 적재분에 대해 `KB개인용자동차보험` 또는 `자동차26-41048-1-04` 문서를 대상으로 `PRODUCT_CODE='41048'` backfill을 수행한다.
3. 검색 필터가 상품코드만 보지 말고 `PRODUCT_NAME`, `CHUNK_TEXT`의 약관 승인번호 패턴도 fallback으로 보도록 보강한다.
VPD 관리 인스턴스에서는 이 영역을 직접 수정하지 않고, 정형 데이터/Select AI 쪽 table comment와 annotation 관리 기능으로 메타데이터 품질을 운영 가능하게 한다.

View File

@@ -0,0 +1,319 @@
# Select AI 프로파일 전환 및 호출 시간 리포트
## 대상
- 날짜: 2026-07-10
- 대상 서비스: VPD 관리 인스턴스 MCP / ORDS Select AI 조회
- MCP endpoint: `https://kb.cloud-handson.com/mcp`
- MCP tool: `ords.query.kb_select_ai_vpd`
- ORDS endpoint: `/ords/cb-ords/kb-select-ai-vpd/query`
- 테스트 질의:
```text
C1001006 고객 자동차보험 갱신 상담 전에, 현재 KB 계약(41048)과 삼성화재 보유 자동차보험 약관을 비교해서 고객에게 설명할 차별 포인트를 정리해줘.
```
## 결론
기존 `openai.gpt-5.5` 기반 Select AI 프로파일은 SQL 생성 시간이 길고, 같은 질의에서도 SQL 생성 실패/거절 응답이 간헐적으로 발생했다.
최종 적용 프로파일은 아래로 변경했다.
```text
KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1
```
이 프로파일은 `openai.gpt-5.4-mini`를 사용하고, `comments=true`를 켜서 테이블/컬럼 comment 기반 SQL 생성을 유지한다. 최종 ORDS 직접 호출은 약 4.1초, MCP 호출은 약 4.6~8.2초 범위로 확인됐다.
## 기존 호출 시간
기존 운영 프로파일:
```text
KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2
model=openai.gpt-5.5
provider=oci
region=us-chicago-1
provider_endpoint=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
comments=true
annotations=true
constraints=true
conversation=true
enforce_object_list=true
```
관찰된 기존 병목:
| 측정 구간 | 시간 | 결과 |
|---|---:|---|
| VPD context 설정 | 282ms | 정상 |
| 확정 SQL + VPD 실행 | 50ms | 정상 |
| `showprompt` metadata/prompt 생성 | 43.817초 | prompt 18,806자 |
| `showsql` 1차 | 38.770초 | SQL 생성 성공 |
| `showsql` 2차 | 105.505초 | non-SQL refusal |
| SQLcl direct package 호출 | 61.123초 | SQL 생성 및 2건 반환 |
| MCP 경로 호출 | 54.3초 | `ORA-20813`, Select AI가 유효 SELECT 생성 실패 |
| GPT-5.5 재비교 호출 | 72초 | SQL 대신 refusal 메시지 반환 |
판단:
- DB/VPD/ORDS 자체가 느린 것이 아니다.
- 주 병목은 `DBMS_CLOUD_AI.GENERATE` 내부의 Select AI prompt 구성과 LLM SQL 생성 단계다.
- `comments`, `annotations`, `constraints`, `conversation`, `enforce_object_list`가 모두 켜진 GPT-5.5 프로파일은 prompt가 커지고 응답 편차가 컸다.
## 후보 프로파일 테스트 결과
### 1. 단순 모델 교체 후보
| 프로파일 | 모델 | 설정 | 결과 |
|---|---|---|---|
| `KB_AIDP_SELECTAI_GPT5_MINI_PROFILE_V1` | `openai.gpt-5-mini` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` |
| `KB_AIDP_SELECTAI_GPT41_MINI_PROFILE_V1` | `openai.gpt-4.1-mini` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` |
| `KB_AIDP_SELECTAI_GROK43_PROFILE_V1` | `xai.grok-4.3` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` |
| `KB_AIDP_SELECTAI_GPT54_MINI_PROFILE_V1` | `openai.gpt-5.4-mini` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` |
오류:
```text
ORA-20404: Object not found - oci://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/chat
```
단순히 모델명만 바꾸는 방식은 안정적이지 않았다.
### 2. 기존 성공 프로파일 방식 확인
기존에 성공한 프로파일:
```text
POC_SELECT_AI_ALL
model=xai.grok-4.3
```
확인된 차이:
- `oci_compartment_id`가 있음
- `comments`, `annotations`, `constraints`, `conversation`, `enforce_object_list` 플래그가 없음
비교 결과:
| 프로파일 | 시간 | 결과 |
|---|---:|---|
| `KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2` | 72초 | refusal 메시지 반환 |
| `POC_SELECT_AI_ALL` | 16초 | SQL 생성 성공 |
### 3. FAST 프로파일 테스트
`POC_SELECT_AI_ALL` 구조를 기준으로 `oci_compartment_id`를 포함하고, 무거운 메타데이터 플래그를 제거한 FAST 후보를 만들었다.
| 프로파일 | 모델 | showprompt | prompt 크기 | showsql | 결과 |
|---|---|---:|---:|---:|---|
| `KB_AIDP_SELECTAI_GROK43_FAST_PROFILE_V1` | `xai.grok-4.3` | 3초 | 5,049자 | 7초 | 성공 |
| `KB_AIDP_SELECTAI_GPT54_MINI_FAST_PROFILE_V1` | `openai.gpt-5.4-mini` | 0초 | 5,049자 | 4초 | 성공 |
FAST 방식은 빠르지만, 테이블/컬럼 comment 활용이 약해진다. 따라서 최종 적용용으로는 `comments=true`만 추가한 절충형을 별도 테스트했다.
### 4. Full metadata 프로파일 테스트
`comments=true`에 더해 `annotations=true`, `constraints=true`까지 켠 후보를 추가 테스트했다.
프로파일:
```text
KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1
model=openai.gpt-5.4-mini
comments=true
annotations=true
constraints=true
enforce_object_list=true
```
DBMS_CLOUD_AI 단독 측정:
| 회차 | showprompt | prompt 크기 | showsql | 결과 |
|---:|---:|---:|---:|---|
| 1 | 32초 | 19,816자 | 15초 | 성공 |
| 2 | 3초 | 19,816자 | 6초 | 성공 |
| 3 | 4초 | 19,816자 | 13초 | 성공 |
ORDS 실제 경로 측정:
| 회차 | HTTP | 총 시간 | 오류 | 반환 건수 |
|---:|---:|---:|---|---:|
| 1 | 200 | 8.224초 | 없음 | 2 |
| 2 | 200 | 7.288초 | 없음 | 2 |
| 3 | 200 | 6.217초 | 없음 | 2 |
평균:
```text
7.243초
```
비교:
| 구성 | ORDS 평균 | 상대 |
|---|---:|---:|
| `comments=true` only | 4.156초 | 1.0x |
| `comments + annotations + constraints` | 7.243초 | 약 1.7x 느림 |
판단:
- Full metadata 구성은 동작한다.
- 단, prompt 크기가 `12,520자`에서 `19,816자`로 증가한다.
- ORDS 기준 평균 응답 시간이 `4.156초`에서 `7.243초`로 늘어난다.
- 데모 응답성 기준으로는 `comments=true` only 구성이 더 적합하다.
## 최종 적용 프로파일
최종 적용:
```text
KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1
model=openai.gpt-5.4-mini
comments=true
enforce_object_list=true
oci_compartment_id=<configured>
```
테스트 결과:
| 구간 | 시간 | 결과 |
|---|---:|---|
| `showprompt` | 6초 | 성공 |
| prompt 크기 | 12,520자 | comment 포함 |
| `showsql` | 4초 | 성공 |
선택 이유:
- GPT-5.5 대비 훨씬 빠르다.
- FAST 프로파일보다 prompt가 크지만, 테이블/컬럼 comment를 유지한다.
- VPD 관리 인스턴스에서 보강한 table/column comment 운영 효과가 Select AI에 반영된다.
## 적용 내용
### DB 패키지
`POC_2.KB_SELECT_AI_VPD_QUERY_API`의 Select AI profile 상수를 변경했다.
```sql
c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1';
```
대상 스크립트:
```text
sql/adb/65_kb_select_ai_vpd_query_api.sql
```
### ORDS 응답 표시
ORDS JSON 응답의 profile 표시도 새 프로파일로 변경했다.
```text
profile=KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1
```
대상 스크립트:
```text
sql/adb/66_kb_select_ai_vpd_query_ords.sql
```
### MCP 응답 표시
MCP tool 응답 payload의 profile 표시와 화면 설명을 새 프로파일 기준으로 변경했다.
대상 소스:
```text
src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
src/main/resources/templates/mcp-sse.html
```
## 최종 속도 측정
### ORDS 직접 호출
호출 대상:
```text
https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords/cb-ords/kb-select-ai-vpd/query
```
| 회차 | HTTP | 총 시간 | 응답 profile | 오류 | 반환 건수 |
|---:|---:|---:|---|---|---:|
| 1 | 200 | 4.058초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 |
| 2 | 200 | 4.216초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 |
| 3 | 200 | 4.195초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 1 |
평균:
```text
4.156초
```
### MCP 호출
호출 대상:
```text
https://kb.cloud-handson.com/mcp
```
| 회차 | HTTP | 총 시간 | MCP payload profile | ORDS profile | 오류 | 반환 건수 |
|---:|---:|---:|---|---|---|---:|
| 1 | 200 | 4.632초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 |
| 2 | 200 | 8.213초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 |
| 3 | 200 | 7.279초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 1 |
평균:
```text
6.708초
```
참고:
- MCP `time_starttransfer`는 약 0.008~0.012초였지만, 최종 응답 body 완료 기준은 `time_total`이다.
- MCP 경로는 백오피스 HTTP 처리와 ORDS 호출 wrapping이 추가되므로 ORDS 직접 호출보다 약간 느릴 수 있다.
## 개선 효과
| 비교 항목 | 기존 GPT-5.5 | 최종 GPT-5.4-mini comments |
|---|---:|---:|
| SQLcl direct package | 61.123초 | 4초대 SQL 생성 |
| 기존 MCP 실패 사례 | 54.3초 후 실패 | 4.6~8.2초 성공 |
| GPT-5.5 재비교 | 72초 후 refusal | 4.1초대 ORDS 성공 |
| prompt 크기 | 18,806~21,776자 | 12,520자 |
정리:
- 최종 ORDS 기준으로 기존 61~72초 구간 대비 약 10~17배 빠르다.
- MCP 기준으로도 기존 54.3초 실패 사례 대비 성공 응답이 약 4.6~8.2초로 줄었다.
- DB/VPD 실행 병목이 아니라 Select AI 프로파일/모델/메타데이터 구성이 핵심 병목이었다.
## 남은 주의점
1. 같은 자연어라도 Select AI 생성 SQL은 완전히 결정적이지 않다.
- 테스트에서도 반환 건수가 1~2건으로 흔들렸다.
- 이는 LLM SQL 생성 특성이다.
2. 비즈니스 품질을 더 안정화하려면 table/column comment와 annotation을 계속 보강해야 한다.
3. 특정 데모 질문은 deterministic SQL fallback 후보로 둘 수 있다.
- 예: `CUST_ID`, `PRODUCT_CD`, `EXT_INSURER`, `EXT_PRODUCT_GRP`가 명확한 비교 질문.
4. 장기적으로는 profile을 설정값으로 분리하는 것이 좋다.
- 현재는 DB package 상수로 고정되어 있다.
- 운영 전환 시 `CB_BACKOFFICE_SETTING` 또는 별도 Select AI 설정 테이블에서 profile을 읽도록 개선할 수 있다.
## 현재 권고
데모/PoC 운영 기준으로는 현재 적용한 아래 프로파일을 유지한다.
```text
KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1
```
이유:
- 응답 시간이 데모 가능한 수준이다.
- comment 기반 스키마 설명을 유지한다.
- ORDS/MCP 양쪽 모두 실제 호출 성공을 확인했다.

View File

@@ -0,0 +1,213 @@
# 데이터 접근 제어 백오피스 전체 UX/기능 리뷰
작성일: 2026-07-13
범위: 현재 Spring Boot 백오피스 화면, VPD/ASO/ORDS/MCP/Select AI 운영 흐름
상태: Review only
## 1. 결론
단순 문구 정리만으로는 충분하지 않다. 지금 화면은 기능은 많이 들어와 있지만, 사용자가 “업무 규칙을 넣으면 DB에서 어떻게 행/컬럼으로 적용되는가”를 끝까지 따라가기 어렵다.
가장 큰 개선 축은 세 가지다.
1. 전체 흐름을 작업 단위로 묶어야 한다.
- 사용자/역할 생성
- 행 접근 규칙 등록
- 보호 정책 연결
- 컬럼 마스킹 설정
- 토큰 발급
- 실제 접근 검증
2. 화면별 기본/고급을 분리해야 한다.
- 기본 화면: 업무 의미, 현재 상태, 다음 버튼, 검증 결과
- 고급 화면: VPD predicate, PL/SQL source, ORDS handler, raw JSON, SQL trace
3. 모든 설정 화면에서 “설정값 → DB 적용 → 실제 검증”이 이어져야 한다.
- 지금은 각 화면이 기능 단위로는 존재하지만, 다음 단계 연결과 검증 루프가 약하다.
## 2. 페르소나별 핵심 불만
| 페르소나 | 현재 불만 | 필요한 개선 |
|---|---|---|
| 일반 사용자 | VPD/ASO/ORDS/MCP가 섞여 무엇을 눌러야 할지 모른다 | “이 사용자는 무엇을 볼 수 있나?” 중심의 단순 경로 |
| 운영 관리자 | 변경 전후 영향과 실제 적용 여부를 한 번에 보기 어렵다 | 영향 사용자, 대상 객체, 검증 버튼, 최근 결과 |
| 적용 담당자 | 업무 조건이 WHERE predicate로 바뀌는 연결이 화면마다 끊긴다 | 업무 조건 → 저장 rule → VPD/ASO 해석 → 검증 결과 |
| DB 관리자 | 백오피스 설정과 DB 실제 정책이 일치하는지 증적이 부족하다 | DBMS_RLS/DBMS_REDACT/FGA/ORDS 상태를 한곳에서 확인 |
| 보안 담당자 | guest/read-only, 토큰 처리, 원문 표시 예외의 경계가 더 명확해야 한다 | 변경 가능 여부, 원문 표시 범위, 감사 증적 |
| 데모/영업 사용자 | KB 보험 시나리오가 화면 흐름으로 자연스럽게 보이지 않는다 | 설계사/지점장/관리자 시나리오 preset과 결과 비교 |
## 3. 우선순위 개선안
### P0. 반드시 해야 할 개선
#### 3.1 대시보드를 “전체 그림 + 오늘 할 일” 중심으로 재구성
현재 대시보드는 구조 설명은 좋아졌지만, 사용자가 다음 행동을 결정하기에는 아직 기능 나열에 가깝다.
개선:
- 상단에 3개 핵심 카드:
- 행 접근: “누가 어떤 행을 보는가”
- 컬럼 마스킹: “허용된 행의 어떤 컬럼을 원문/마스킹으로 보는가”
- 접근 검증: “토큰으로 실제 DB 결과를 확인한다”
- “처음 설정” 체크리스트:
1. 사용자/역할 준비
2. 행 접근 규칙 등록
3. 보호 상태 적용
4. 컬럼 마스킹 연결
5. 토큰 발급
6. 접근 검증
- DB 상태 요약:
- VPD 정책 누락 수
- ASO 정책 불일치 수
- ORDS handler 누락 수
- 최근 검증 실패 수
#### 3.2 접근 검증 결과 화면을 탭/단계형으로 재설계
접근 검증은 이 백오피스의 최종 판단 화면이다. 현재도 결과는 나오지만, “왜 그렇게 나왔는지”를 단계별로 보기에는 부족하다.
개선:
- 결과를 4개 섹션으로 분리:
1. 토큰 해석 결과: 사용자, stakeholder, role, channel
2. 행 접근 결과: 반환 행 수, 적용된 VPD predicate, ALLOW/DENY 근거
3. 컬럼 마스킹 결과: 마스킹 대상 컬럼, 원문 허용 여부, ASO policy 상태
4. DB 감사 증적: FGA SQL, RLS_INFO, request id
- 잘못된 토큰은 DB 오류처럼 보이지 않게:
- 토큰 없음
- 등록되지 않은 토큰
- 만료/회수 토큰
- 권한 없음
을 분리한다.
- 결과 테이블에서 마스킹된 컬럼에 아이콘/툴팁 표시.
#### 3.3 행 접근 규칙 화면에 “업무 조건 → predicate 변환”을 더 강하게 표시
현재도 wizard와 preview가 있으나, 적용 담당자가 원하는 것은 “내가 고른 업무 조건이 실제 어떤 WHERE 조각이 되는가”다.
개선:
- 조건 선택 옆에 즉시 preview:
- 본인 담당 계약 → `FC_ID = SYS_CONTEXT(...STAKEHOLDER_USER_ID...)`
- 채널 고객 → `EXISTS (...) FC_CHANNEL = SYS_CONTEXT(...STAKEHOLDER_CHANNEL...)`
- 정적 SQL 조건 → 검증된 현재 객체 컬럼 조건만 허용
- 목록 기본값은 raw rule보다 업무 문장 우선.
- 상세에는 저장 rule, 변환 predicate, 결합 방식(AND/OR/DENY)을 같이 표시.
- 삭제/변경 시 영향 사용자 수와 최근 검증 결과 링크 표시.
#### 3.4 컬럼 마스킹 화면에서 설정 단계와 DB 적용 상태를 더 선명하게 분리
지금 기능은 맞지만 사용자에게는 “대상 컬럼 추가”, “규칙 연결”, “사용자 원문 허용”, “DB 정책 동기화”가 섞여 보일 수 있다.
개선:
- 단계형 표시:
1. 마스킹 후보 컬럼 등록
2. 기본 마스킹 규칙 연결
3. 원문 표시 허용 사용자 지정
4. DB ASO 정책 동기화 상태 확인
- 컬럼별 “현재 실제 동작” preview:
- 일반 사용자: 마스킹
- 원문 허용 사용자: 원문
- 행 접근 권한 없는 사용자: 행 없음
- DBMS_REDACT 정책 상태와 백오피스 설정 차이를 컬럼 단위로 표시.
#### 3.5 운영 현황을 통합 health dashboard로 강화
현재 운영 현황은 표가 있지만, 장애 상황에서 “무엇이 문제인지”를 바로 알려주는 구조가 약하다.
개선:
- 상단 전체 상태:
- 정상 / 주의 / 장애
- 영역별 health:
- App 로그인/세션
- DB 연결
- VPD 정책
- ASO 정책
- ORDS handler
- MCP/Select AI endpoint
- 각 상태에:
- 마지막 확인 시각
- 영향받는 기능
- 다음 조치
- 관련 화면 이동 링크
## 4. 화면별 추가 리뷰
| 화면 | 현재 상태 | 남은 개선 |
|---|---|---|
| 로그인 | 제품명 정리됨 | 백오피스 계정과 업무 사용자 토큰이 다르다는 안내, guest/read-only 안내 보강 |
| 대시보드 | 전체 구조 설명 있음 | 실제 작업 체크리스트와 상태 요약 부족 |
| 사용자 | application user 설명 있음 | `KB_STAKEHOLDERS` 매핑 상태, 직접 역할/그룹 역할/토큰 발급 링크 부족 |
| 그룹 | 영향 사용자/역할 일부 표시 | 그룹이 지점/채널 조건이 아니라 역할 상속 단위라는 설명 보강 |
| 역할 | 삭제 영향 표시 있음 | 역할별 접근 규칙 수, 원문 허용 컬럼 수, 영향 사용자 요약 보강 |
| 행 접근 규칙 | wizard와 preview 있음 | 업무 조건별 predicate preview, 삭제 영향/검증 링크 보강 |
| 컬럼 원문 표시 허용 | VPD/ASO 경계 설명 있음 | 조건부 원문 허용이 아니라 사용자 단위 UNMASK라는 한계 명시 필요 |
| 사용자별 접근 확인 | 설정 기반 권한 확인 가능 | 실제 DB 결과가 아니라 예상 권한이라는 구분과 접근 검증 CTA 강화 |
| 보호 상태 | VPD 적용 상태 확인 가능 | `설정됨 / DB 적용됨 / 최근 검증 성공` 3단계 배지 필요 |
| 컬럼 마스킹 | ASO 동기화 기능 있음 | 컬럼별 실제 사용자 결과 preview와 정책 불일치 원인 설명 필요 |
| 검증 세션 | 토큰 발급/이력 있음 | “토큰은 권한을 담지 않고 사용자 식별만 한다”를 더 강하게 표시 |
| 접근 검증 | 핵심 검증 가능 | 결과를 토큰/context/행/컬럼/감사 증적으로 분리 필요 |
| 조회 대상 | ORDS 대상 등록 가능 | 신규 대상 등록 후 다음 단계 안내 부족 |
| 정형 데이터 조회 | 관리자 preview 가능 | 사용자별 접근 결과가 아님을 더 강하게 표시, 권한 기준 컬럼 배지 필요 |
| 조회 연동 | ORDS source 확인 가능 | 기본은 endpoint 상태, source는 Advanced로 더 숨기는 편이 좋음 |
| 지식 검색 | 등록/검색/정책이 한 화면 | 자료 등록, 접근 정책, 검색 검증을 탭으로 분리 필요 |
| 대화형 검색 | 자연어 질의 가능 | 결과를 답변/정형 결과/근거 문서/오류 원인으로 분리 필요 |
| 검색 해석 | 라우팅 결과 확인 가능 | 단계별 타임라인과 소요시간, showprompt/showsql Advanced 필요 |
| MCP 서비스 | endpoint/tool 설명 있음 | 복사 가능한 client 설정 명세와 curl 예시를 상단에 제공 |
| 연동 점검 | client 호출 가능 | 연결 가능/도구 호출 가능/권한 적용 결과 3단계 health 필요 |
| 운영 현황 | 정책/ORDS/ASO 상태 표 있음 | 통합 health, 최근 확인 시각, 영향 범위, 조치 링크 필요 |
| 행 접근 필터 구조 | 기술 흐름 있음 | 함수 소스보다 블록별 설명과 Git/DB source 차이 표시가 우선 |
| DB 메타데이터 | comment/annotation 수정 가능 | 권한 기준 컬럼 배지, Select AI 실패 리포트와 연결 필요 |
| 보안 SQL 스크립트 | 원문/LLM 설명 가능 | 스크립트별 목적/대상/변경 정책/검증 방법 summary card 필요 |
| 고급 접근 조건 | Advanced 성격 있음 | 기본 접근 규칙으로 해결 가능한지 체크리스트 선행 필요 |
| 시스템 설정 | ORDS Base URL 관리 | 저장 후 자동 health check와 영향 기능 표시 필요 |
| DB 준비 상태 | preflight와 DDL 있음 | 확인 작업과 변경 작업을 더 강하게 분리, 실행 전 영향 요약 필요 |
## 5. 설계상 더 명확히 해야 할 원칙
### 5.1 토큰은 권한 묶음이 아니다
토큰은 사용자를 식별하고 context를 세팅하는 열쇠다. 실제 권한은 요청 시점에 사용자/그룹/역할/행 접근 규칙/마스킹 규칙을 조회해서 계산된다.
화면 전반에 이 문장을 반복해야 한다.
### 5.2 VPD와 ASO의 결합 방식
- VPD는 행을 줄인다.
- ASO는 남은 행의 컬럼 표시 방식을 바꾼다.
- 원문 표시 허용은 행 접근 권한을 늘리지 않는다.
- 행 접근 권한이 없으면 ASO 원문 허용도 의미가 없다.
### 5.3 “집계만 허용”은 별도 설계가 필요하다
ASO로 마스킹된 컬럼에 대해 자연스럽게 집계가 된다고 가정하면 안 된다. 지점장에게 상세는 마스킹하고 집계만 허용하려면 trusted API, aggregate 전용 path, 또는 별도 검증 가능한 query boundary가 필요하다.
### 5.4 Select AI 품질은 메타데이터 운영 문제다
자연어 질의 실패를 프롬프트로만 해결하면 재현성이 떨어진다. 테이블/컬럼 comment, annotation, constraint, 업무명, 조인 키를 운영자가 보강하는 흐름이 있어야 한다.
## 6. 추천 구현 순서
### 1차: 운영 사고를 줄이는 P0
1. 접근 검증 결과 화면 재구성
2. 운영 현황 health dashboard 강화
3. 행 접근 규칙 predicate preview/영향도 강화
4. 컬럼 마스킹 단계형 구성과 사용자별 preview
### 2차: 온보딩과 이해도 개선
1. 대시보드 체크리스트와 상태 요약
2. 사용자/역할/그룹 영향도 보강
3. 보호 상태 3단계 배지
4. 조회 대상 등록 후 다음 단계 안내
### 3차: MCP/Select AI 품질과 고급 운영
1. DB 메타데이터 권한 기준 컬럼 배지
2. MCP/검색 결과 타임라인과 소요시간 표시
3. 보안 SQL 스크립트 summary card
4. 고급 접근 조건 체크리스트와 검증 강화

View File

@@ -2,12 +2,14 @@
## 운영 구조 ## 운영 구조
- 공개 endpoint: `https://<소유 FQDN>` 또는 `https://<고정 public IPv4>` - 공개 endpoint: `https://kb.cloud-handson.com`
- Caddy: VM의 80/443에서 TLS termination, HTTP redirect, HSTS, 인증서 자동 발급·갱신 - 공개 배포 VM: `opc@161.33.6.45`
- Spring Boot: `127.0.0.1:8082`에서만 수신 - Nginx: VM의 80/443에서 TLS termination, HTTP redirect, HSTS
- 외부 `8082/tcp`: OCI NSG와 VM firewalld 모두 deny - Spring Boot: `127.0.0.1:8080`에서만 수신
- 외부 애플리케이션 포트 직접 접근: OCI NSG와 VM firewalld 모두 deny
- 인증서: Lets Encrypt / Certbot Nginx plugin
FQDN을 쓰면 A 레코드는 VM public IP를 가리켜야 한다. DNS가 없는 고정 public IPv4는 Lets Encrypt `shortlived` profile 기반 약 6일 인증서를 사용하며 Caddy 자동 갱신이 정상인지 반드시 모니터링한다. 임시 공개 DNS 서비스와 Caddy 로컬 CA 인증서는 운영 endpoint로 사용하지 않는다. FQDN A 레코드는 VM public IP `161.33.6.45`를 가리켜야 한다. 현재 운영 경로는 Nginx가 `127.0.0.1:8080``vpd-backoffice.service`로 프록시하는 구조다. `hermes` 또는 `8082` 응답만 보고 운영 반영 완료로 판단하지 않는다.
## 최초 준비 ## 최초 준비
@@ -16,15 +18,8 @@ FQDN을 쓰면 A 레코드는 VM public IP를 가리켜야 한다. DNS가 없는
1. 소유 FQDN의 A 레코드를 VM public IP로 설정하거나 고정 public IPv4 사용을 확정한다. 1. 소유 FQDN의 A 레코드를 VM public IP로 설정하거나 고정 public IPv4 사용을 확정한다.
2. OCI NSG와 VM firewalld에서 80/443 ingress를 허용한다. 2. OCI NSG와 VM firewalld에서 80/443 ingress를 허용한다.
3. 기존 8082 ingress를 OCI NSG와 firewalld에서 제거한다. 3. 기존 8082 ingress를 OCI NSG와 firewalld에서 제거한다.
4. Oracle Linux/RHEL 계열 VM에 공식 Caddy 패키지를 설치한다. 4. Oracle Linux/RHEL 계열 VM에 Nginx와 Certbot Nginx plugin을 설치한다.
5. `vpd-backoffice.service``127.0.0.1:8080`에만 바인딩한다.
```bash
sudo dnf install -y dnf-plugins-core
sudo dnf copr enable -y @caddy/caddy
sudo dnf install -y caddy
```
공식 패키지는 `caddy.service``/etc/caddy/Caddyfile`을 제공한다. 설정 스크립트가 service enable/start를 처리한다.
## 적용 ## 적용
@@ -32,78 +27,58 @@ sudo dnf install -y caddy
```bash ```bash
mvn test mvn test
scripts/test-backoffice-https-config.sh
scripts/deploy-backoffice-vm.sh --host hermes
``` ```
HTTPS 설정을 dry-run으로 확인한 다음 적용한다. 운영 배포는 `161.33.6.45` 대상에 수행한다. SSH alias를 쓴다면 해당 alias가 반드시 `opc@161.33.6.45`를 가리키는지 먼저 확인한다.
```bash ```bash
scripts/configure-backoffice-https-vm.sh \ ssh <운영-alias> 'hostname; hostname -I; systemctl status vpd-backoffice --no-pager'
--host hermes \
--public-host admin.example.com \
--tls-email ops@example.com \
--expected-address 130.162.134.59 \
--dry-run
scripts/configure-backoffice-https-vm.sh \
--host hermes \
--public-host admin.example.com \
--tls-email ops@example.com \
--expected-address 130.162.134.59
``` ```
실제 endpoint, 이메일, 주소로 바꿔 실행한다. FQDN 대신 IP를 쓰는 현재 hermes 예시는 `--public-host 130.162.134.59 --expected-address 130.162.134.59`다. 스크립트는 DNS/IP 일치, Caddy/sudo, Caddyfile 문법, service 상태를 확인하고 기존 설정을 `~/apps/vpd-backoffice/caddy-backups`에 저장한다. 배포 후에는 systemd 서비스를 재시작하고 공개 URL로 확인한다.
```bash
ssh <운영-alias> 'sudo systemctl restart vpd-backoffice'
curl -k -sS https://kb.cloud-handson.com/login
```
## 반복 검증 ## 반복 검증
설정을 바꾸지 않고 외부 검증만 다시 수행할 수 있다. 설정을 바꾸지 않고 외부 검증만 다시 수행할 수 있다.
```bash
scripts/configure-backoffice-https-vm.sh \
--host hermes \
--public-host admin.example.com \
--tls-email ops@example.com \
--expected-address 130.162.134.59 \
--verify-only
```
검증 항목: 검증 항목:
- HTTP `/login`이 동일 host의 HTTPS로 전환됨 - HTTP `/login`이 동일 host의 HTTPS로 전환됨
- HTTPS 인증서가 공개 신뢰됨 - HTTPS 인증서가 공개 신뢰됨
- HSTS 1년 - HSTS 1년
- `JSESSIONID`의 Secure/HttpOnly/SameSite=Lax - `JSESSIONID`의 Secure/HttpOnly/SameSite=Lax
- 외부 `:8082` 직접 연결 실패 - 외부 애플리케이션 포트 직접 연결 실패
- `https://kb.cloud-handson.com/login`의 HTML이 현재 배포된 jar의 로그인 화면과 일치
## 인증서 갱신과 모니터링 ## 인증서 갱신과 모니터링
Caddy는 공개 DNS 이름의 인증서를 자동 갱신하며, 공식 systemd service의 인증서 상태는 `/var/lib/caddy/.local/share/caddy`에 유지된다. 별도 cron이나 certbot hook을 추가하지 않는다. Certbot timer가 Lets Encrypt 인증서를 갱신한다. Nginx 설정과 인증서 갱신 상태를 함께 확인한다.
```bash ```bash
ssh hermes 'systemctl is-active caddy && systemctl is-enabled caddy' ssh <운영-alias> 'systemctl is-active nginx && systemctl is-enabled nginx'
ssh hermes 'sudo journalctl -u caddy --since "24 hours ago" --no-pager' ssh <운영-alias> 'systemctl is-active certbot-renew.timer && systemctl is-enabled certbot-renew.timer'
ssh hermes 'sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile' ssh <운영-alias> 'sudo nginx -t'
ssh <운영-alias> 'sudo journalctl -u nginx --since "24 hours ago" --no-pager'
``` ```
ACME 오류, 인증서 만료 경고, 반복 reload 실패를 알림 대상으로 삼는다. VM 백업에서 Caddy data directory와 앱의 Caddyfile backup을 함께 보존한다. ACME 오류, 인증서 만료 경고, 반복 reload 실패를 알림 대상으로 삼는다.
## 장애와 롤백 ## 장애와 롤백
1. 앱이 살아 있는지 VM 내부에서 확인한다. 1. 앱이 살아 있는지 VM 내부에서 확인한다.
```bash ```bash
ssh hermes 'curl -sS -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: admin.example.com" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8082/login' ssh <운영-alias> 'curl -sS -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: kb.cloud-handson.com" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/login'
``` ```
2. Caddy 로그와 설정을 확인한다. 2. Nginx 로그와 설정을 확인한다.
3. 설정 변경 직후 장애라면 가장 최근 backup을 복원한다. 3. 설정 변경 직후 장애라면 가장 최근 Nginx 설정 backup을 복원한다.
```bash 4. 앱 jar 롤백이 필요하면 직전 승인된 artifact를 배포하고 앱과 Nginx를 모두 재검증한다.
ssh hermes 'ls -1t ~/apps/vpd-backoffice/caddy-backups/Caddyfile.* | head -n 3'
ssh hermes 'sudo install -o root -g root -m 644 ~/apps/vpd-backoffice/caddy-backups/Caddyfile.<timestamp> /etc/caddy/Caddyfile && sudo systemctl reload caddy'
```
4. 앱 jar 롤백이 필요하면 직전 승인된 artifact를 배포하고 앱과 Caddy를 모두 재검증한다. 장애 우회를 위해 애플리케이션 포트를 직접 공개하지 않는다. 서비스 중단이나 방화벽/NSG 롤백은 개별 승인을 받는다.
장애 우회를 위해 `8082`를 다시 공개하지 않는다. 서비스 중단이나 방화벽/NSG 롤백은 개별 승인을 받는다.

View File

@@ -0,0 +1 @@
vm.max_map_count = 524288

View File

@@ -0,0 +1,15 @@
server {
listen 80;
listen [::]:80;
server_name sonarcube.cloud-handson.com;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}

View File

@@ -0,0 +1,20 @@
[Unit]
Description=SonarQube Community Build (evaluation)
After=network.target
[Service]
Type=simple
User=opc
Group=opc
WorkingDirectory=/home/opc/tools/sonarqube-26.6.0.123539
Environment=SONAR_WEB_HOST=127.0.0.1
Environment=SONAR_WEB_PORT=9000
ExecStart=/bin/bash /home/opc/tools/sonarqube-26.6.0.123539/bin/linux-x86-64/sonar.sh console
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
LimitNOFILE=131072
LimitNPROC=8192
[Install]
WantedBy=multi-user.target

43
pom.xml
View File

@@ -19,10 +19,12 @@
<relativePath/> <relativePath/>
</parent> </parent>
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<mybatis-spring-boot.version>3.0.4</mybatis-spring-boot.version> <mybatis-spring-boot.version>3.0.4</mybatis-spring-boot.version>
<oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version> <oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version>
<!-- GPT-5.5 response/schema support is supplied by the current OCI SDK. -->
<oci.sdk.version>3.90.1</oci.sdk.version>
</properties> </properties>
<dependencies> <dependencies>
@@ -57,6 +59,21 @@
<artifactId>oraclepki</artifactId> <artifactId>oraclepki</artifactId>
<version>${oracle.jdbc.version}</version> <version>${oracle.jdbc.version}</version>
</dependency> </dependency>
<!-- OCI API-key signing for the SQL-script explanation assistant. The
private key remains in the configured OCI config file, never in the
application environment or Git. -->
<dependency>
<groupId>com.oracle.oci.sdk</groupId>
<artifactId>oci-java-sdk-generativeaiinference</artifactId>
<version>${oci.sdk.version}</version>
</dependency>
<!-- OCI SDK service modules need exactly one concrete HTTP transport. The
Jersey 3 transport is compatible with this Spring Boot 3 application. -->
<dependency>
<groupId>com.oracle.oci.sdk</groupId>
<artifactId>oci-java-sdk-common-httpclient-jersey3</artifactId>
<version>${oci.sdk.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -71,6 +88,24 @@
</dependencies> </dependencies>
<build> <build>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
only the curated read-only viewer set into the deployed JAR. -->
<resource>
<directory>sql/adb</directory>
<targetPath>sql/adb</targetPath>
<includes>
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
<include>63_kb_aso_masking_rule_runtime.sql</include>
<include>64_kb_aso_masking_default_column_rules.sql</include>
<include>65_kb_select_ai_vpd_query_api.sql</include>
<include>66_kb_select_ai_vpd_query_ords.sql</include>
</includes>
</resource>
</resources>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

21
scripts/enqueue.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# 새 작업을 파이프라인 큐에 투입 = 01-Planner/신규 Redmine 이슈 생성.
# 사용법: ./scripts/enqueue.sh "제목" ["요구사항"]
set -euo pipefail
cd "$(dirname "$0")/.."
set -a; . ./.env; set +a
SUBJECT="${1:?사용법: enqueue.sh \"제목\" [\"요구\"]}"; BODY="${2:-}"
PLANNER=$(curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
"$REDMINE_URL/projects/$REDMINE_PROJECT/issue_categories.json" \
| python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='01-Planner']")
DESC=$(printf '## [AI] Planner\n\n(요구사항)\n%s\n\n---\nWorking dir: %s' "$BODY" "$(pwd)")
python3 - "$REDMINE_URL" "$REDMINE_API_KEY" "$REDMINE_PROJECT" "$SUBJECT" "$DESC" "$PLANNER" <<'PY'
import sys,json,urllib.request
base,key,proj,subject,desc,cat=sys.argv[1:7]
p={"issue":{"project_id":proj,"tracker_id":2,"subject":subject,"description":desc,
"category_id":int(cat),"status_id":1}}
r=urllib.request.Request(base+"/issues.json",data=json.dumps(p).encode(),
headers={"X-Redmine-API-Key":key,"Content-Type":"application/json"},method="POST")
i=json.load(urllib.request.urlopen(r))["issue"]
print(f"enqueued #{i['id']}: {i['subject']} -> 01-Planner/신규")
PY

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Provisions the single DDS technical END USER used by business-user searches.
# Required environment: DDS_ADMIN_DB_URL, DDS_ADMIN_USERNAME,
# DDS_ADMIN_PASSWORD, DDS_TOKEN_PASSWORD.
set -Eeuo pipefail
: "${DDS_ADMIN_DB_URL:?set DDS_ADMIN_DB_URL}"
: "${DDS_ADMIN_USERNAME:?set DDS_ADMIN_USERNAME}"
: "${DDS_ADMIN_PASSWORD:?set DDS_ADMIN_PASSWORD}"
: "${DDS_TOKEN_PASSWORD:?set DDS_TOKEN_PASSWORD}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
connect="${DDS_ADMIN_USERNAME}/${DDS_ADMIN_PASSWORD}@${DDS_ADMIN_DB_URL}"
sqlplus -s "$connect" <<SQL
WHENEVER SQLERROR EXIT SQL.SQLCODE
DEFINE DDSUSER_TOKEN_PASSWORD = '${DDS_TOKEN_PASSWORD}'
@${root}/sql/adb/34_dds_token_data_grant_common_auth.sql
@${root}/sql/adb/42_dds_fga_execution_evidence.sql
PROMPT === DDS token search verification ===
SELECT username FROM dba_users WHERE username = 'dds_demo_token';
SELECT grant_name, object_name, grantee
FROM dba_data_grants
WHERE grant_name = 'DDS_DEMO_TOKEN_VECTOR_GRANT';
EXIT
SQL
echo 'Provisioning completed. Configure only these server Secret values:'
echo ' DDS_BACKOFFICE_DB_URL=<DB JDBC URL>'
echo ' DDS_BACKOFFICE_TOKEN_USERNAME=dds_demo_token'
echo ' DDS_BACKOFFICE_TOKEN_PASSWORD=<DDS_TOKEN_PASSWORD value>'

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# ============================================================
# Agent ORDS security ADB local-only executable example.
#
# This script does not call RDS, DB Link, Postgres, or MySQL.
# It creates local ADB tables/views and verifies VPD + DDS.
# ============================================================
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ ! -f "$ROOT/.env" ]]; then
echo "[FAIL] .env not found. Copy .env.example to .env and fill ADB_* values." >&2
exit 1
fi
set -a
# shellcheck disable=SC1091
. "$ROOT/.env"
set +a
run_admin() {
local sql_file="$1"
echo
echo "[ADMIN] @$sql_file"
sqlplus -S -L "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" @"$sql_file"
}
run_as() {
local user="$1" password="$2" sql_file="$3"
echo
echo "[$user] @$sql_file"
sqlplus -S -L "${user}/${password}@${ADB_TNS}" @"$sql_file"
}
run_admin "$ROOT/sql/adb/16_agent_ords_security_local_cleanup.sql"
run_admin "$ROOT/sql/adb/17_agent_ords_security_local_vpd_setup.sql"
run_admin "$ROOT/sql/adb/19_agent_ords_security_local_dds_setup.sql"
run_admin "$ROOT/sql/adb/21_agent_ords_security_ords_enable_schema.sql"
run_as "cb_ords" "CbOrdS#2026Local1" "$ROOT/sql/adb/22_agent_ords_security_ords_handler_setup.sql"
run_admin "$ROOT/sql/adb/24_agent_ords_security_inventory.sql"
run_as "cb_ords" "CbOrdS#2026Local1" "$ROOT/sql/adb/18_agent_ords_security_local_vpd_test.sql"
run_as "cb_ords" "CbOrdS#2026Local1" "$ROOT/sql/adb/23_agent_ords_security_ords_handler_test.sql"
run_as '"cb_dds_hr"' "CbDds#Hr2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql"
run_as '"cb_dds_fin"' "CbDds#Fin2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql"
run_as '"cb_dds_all"' "CbDds#All2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql"
run_as '"cb_dds_none"' "CbDds#None2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql"
echo
echo "[OK] Agent ORDS security ADB local-only VPD + DDS executable example complete"

View File

@@ -0,0 +1,94 @@
-- ============================================================
-- 16_agent_ords_security_local_cleanup.sql
-- Agent ORDS security local-only cleanup.
--
-- This removes only CB_* objects used by the local ADB example.
-- It does not touch the existing RDS DB Link POC objects.
-- ============================================================
WHENEVER SQLERROR CONTINUE
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Cleaning VPD policy ===
BEGIN
DBMS_REDACT.DROP_POLICY(
object_schema => USER,
object_name => 'CB_V_SEARCH_DOCUMENTS',
policy_name => 'CB_CONTENTS_REDACT'
);
EXCEPTION
WHEN OTHERS THEN NULL;
END;
/
BEGIN
DBMS_RLS.DROP_POLICY(
object_schema => USER,
object_name => 'CB_V_SEARCH_DOCUMENTS',
policy_name => 'CB_AGENT_DOC_POLICY'
);
EXCEPTION
WHEN OTHERS THEN NULL;
END;
/
PROMPT === Cleaning DDS data grants ===
BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_hr_docs'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_fin_docs'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_all_docs'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Cleaning DDS end users and roles ===
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_hr"'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_fin"'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_all"'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_none"'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_hr_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_fin_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_all_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_connect_only_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP ROLE cb_dds_connect_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Cleaning DB user and local objects ===
BEGIN EXECUTE IMMEDIATE 'DROP USER cb_ords CASCADE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP VIEW cb_dds_v_search_documents'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP FUNCTION cb_agent_doc_vpd_filter'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP CONTEXT cb_agent_ctx'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP PACKAGE cb_agent_ctx_pkg'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_agent_bearer_key PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_permission_rule PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_permission PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_user_role PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_app_role PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_app_user PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_dds_documents PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Agent ORDS security local cleanup complete ===
EXIT;

View File

@@ -0,0 +1,115 @@
-- ============================================================
-- 18_agent_ords_security_local_vpd_test.sql
-- Run as CB_ORDS.
--
-- Expected:
-- no context -> 0 rows
-- cb_hr_key -> HR rows only, 3 rows
-- cb_fin_key -> owner_emp_no = E2001 only, 1 row
-- cb_all_key -> all rows, 6 rows
-- contents column -> NULL unless CAN_READ_CONTENTS = Y
-- invalid key -> ORA-20002, then 0 rows
-- direct base table -> ORA-00942
-- ============================================================
WHENEVER SQLERROR CONTINUE
SET ECHO OFF
SET FEEDBACK ON
SET LINESIZE 220
SET PAGESIZE 100
COLUMN ctx_user_id FORMAT A12
COLUMN ctx_emp_no FORMAT A12
COLUMN ctx_dept_code FORMAT A12
COLUMN ctx_can_read_contents FORMAT A22
COLUMN title FORMAT A28
COLUMN owner_emp_no FORMAT A12
COLUMN dept_code FORMAT A10
COLUMN contents FORMAT A35
PROMPT
PROMPT === VPD 1. No Bearer key / context: fail closed ===
BEGIN
admin.cb_agent_ctx_pkg.clear_user;
END;
/
SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id,
SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no,
SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code,
SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents
FROM dual;
SELECT COUNT(*) AS rows_visible
FROM admin.cb_v_search_documents;
PROMPT
PROMPT === VPD 2. Authorization: Bearer cb_hr_key -> HR department rows ===
BEGIN
admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_hr_key');
END;
/
SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id,
SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no,
SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code,
SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents
FROM dual;
SELECT doc_id, title, owner_emp_no, dept_code, contents
FROM admin.cb_v_search_documents
ORDER BY doc_id;
SELECT COUNT(*) AS rows_visible
FROM admin.cb_v_search_documents;
PROMPT
PROMPT === VPD 3. Authorization: Bearer cb_fin_key -> self row only ===
BEGIN
admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_fin_key');
END;
/
SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id,
SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no,
SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code,
SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents
FROM dual;
SELECT doc_id, title, owner_emp_no, dept_code, contents
FROM admin.cb_v_search_documents
ORDER BY doc_id;
SELECT COUNT(*) AS rows_visible
FROM admin.cb_v_search_documents;
SELECT doc_id, title, owner_emp_no, dept_code, contents
FROM admin.cb_v_search_documents
ORDER BY doc_id
FETCH FIRST 3 ROWS ONLY;
PROMPT
PROMPT === VPD 4. Authorization: Bearer cb_all_key -> all rows ===
BEGIN
admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_all_key');
END;
/
SELECT COUNT(*) AS rows_visible
FROM admin.cb_v_search_documents;
PROMPT
PROMPT === VPD 5. Invalid Bearer key -> ORA-20002 and context cleared ===
BEGIN
admin.cb_agent_ctx_pkg.set_user_by_bearer('wrong_key');
END;
/
SELECT COUNT(*) AS rows_visible_after_invalid_key
FROM admin.cb_v_search_documents;
PROMPT
PROMPT === VPD 6. Bypass attempts: direct base/permission tables are not granted ===
SELECT COUNT(*) FROM admin.cb_search_documents;
SELECT COUNT(*) FROM admin.cb_app_user;
SELECT COUNT(*) FROM admin.cb_agent_bearer_key;
EXIT;

View File

@@ -0,0 +1,89 @@
-- ============================================================
-- 19_agent_ords_security_local_dds_setup.sql
-- Local-only executable DDS example for Agent ORDS security.
--
-- Scenario:
-- * DDS uses END USER -> DATA ROLE -> DATA GRANT.
-- * No permission table lookup is used.
-- * Each DATA GRANT declares the protected object, row filter,
-- and column restriction.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === 1. Creating DDS-only local table and view ===
CREATE TABLE cb_dds_documents (
doc_id NUMBER PRIMARY KEY,
title VARCHAR2(100) NOT NULL,
owner_emp_no VARCHAR2(20) NOT NULL,
dept_code VARCHAR2(20) NOT NULL,
contents VARCHAR2(4000),
created_at DATE DEFAULT SYSDATE NOT NULL
);
INSERT INTO cb_dds_documents VALUES (1, 'HR payroll guide', 'E1001', 'HR', 'Payroll policy and HR guide', SYSDATE);
INSERT INTO cb_dds_documents VALUES (2, 'HR recruiting plan', 'E1002', 'HR', 'Recruiting plan for HR team', SYSDATE);
INSERT INTO cb_dds_documents VALUES (3, 'Finance close checklist','E2001', 'FIN', 'Monthly close checklist', SYSDATE);
INSERT INTO cb_dds_documents VALUES (4, 'Finance audit memo', 'E2002', 'FIN', 'Audit memo for finance team', SYSDATE);
INSERT INTO cb_dds_documents VALUES (5, 'Sales forecast', 'E3001', 'SALES', 'Quarterly sales forecast', SYSDATE);
INSERT INTO cb_dds_documents VALUES (6, 'HR benefits notice', 'E1003', 'HR', 'Benefits notice for employees', SYSDATE);
CREATE OR REPLACE VIEW cb_dds_v_search_documents AS
SELECT doc_id,
title,
owner_emp_no,
dept_code,
contents,
created_at
FROM cb_dds_documents;
COMMIT;
PROMPT === 2. Creating DDS end users ===
CREATE END USER "cb_dds_hr" IDENTIFIED BY "CbDds#Hr2026Local1";
CREATE END USER "cb_dds_fin" IDENTIFIED BY "CbDds#Fin2026Local1";
CREATE END USER "cb_dds_all" IDENTIFIED BY "CbDds#All2026Local1";
CREATE END USER "cb_dds_none" IDENTIFIED BY "CbDds#None2026Local1";
PROMPT === 3. Creating DATA ROLEs and connection carrier role ===
CREATE ROLE cb_dds_connect_role;
GRANT CREATE SESSION TO cb_dds_connect_role;
CREATE DATA ROLE cb_dds_hr_role;
CREATE DATA ROLE cb_dds_fin_role;
CREATE DATA ROLE cb_dds_all_role;
CREATE DATA ROLE cb_dds_connect_only_role;
GRANT cb_dds_connect_role TO cb_dds_hr_role;
GRANT cb_dds_connect_role TO cb_dds_fin_role;
GRANT cb_dds_connect_role TO cb_dds_all_role;
GRANT cb_dds_connect_role TO cb_dds_connect_only_role;
PROMPT === 4. Mapping END USERs to DATA ROLEs ===
GRANT DATA ROLE cb_dds_hr_role TO "cb_dds_hr";
GRANT DATA ROLE cb_dds_fin_role TO "cb_dds_fin";
GRANT DATA ROLE cb_dds_all_role TO "cb_dds_all";
GRANT DATA ROLE cb_dds_connect_only_role TO "cb_dds_none";
PROMPT === 5. Creating DATA GRANTs ===
CREATE DATA GRANT admin.cb_dg_hr_docs
AS SELECT (ALL COLUMNS EXCEPT contents)
ON admin.cb_dds_v_search_documents
WHERE dept_code = 'HR'
TO cb_dds_hr_role;
CREATE DATA GRANT admin.cb_dg_fin_docs
AS SELECT (ALL COLUMNS EXCEPT contents)
ON admin.cb_dds_v_search_documents
WHERE dept_code = 'FIN'
TO cb_dds_fin_role;
CREATE DATA GRANT admin.cb_dg_all_docs
AS SELECT
ON admin.cb_dds_v_search_documents
TO cb_dds_all_role;
PROMPT === DDS local setup complete ===
EXIT;

View File

@@ -0,0 +1,46 @@
-- ============================================================
-- 20_agent_ords_security_local_dds_test.sql
-- Run as cb_dds_hr / cb_dds_fin / cb_dds_all / cb_dds_none.
--
-- Expected:
-- cb_dds_hr -> 3 rows from admin.cb_dds_v_search_documents
-- cb_dds_fin -> 2 rows from admin.cb_dds_v_search_documents
-- cb_dds_all -> 6 rows from admin.cb_dds_v_search_documents
-- contents -> NULL for hr/fin grants, visible for all grant
-- cb_dds_none -> ORA-00942 on admin.cb_dds_v_search_documents
-- base table -> ORA-00942 for every DDS end user
-- ============================================================
WHENEVER SQLERROR CONTINUE
SET ECHO OFF
SET FEEDBACK ON
SET LINESIZE 220
SET PAGESIZE 100
COLUMN end_user_name FORMAT A16
COLUMN title FORMAT A28
COLUMN owner_emp_no FORMAT A12
COLUMN dept_code FORMAT A10
COLUMN contents FORMAT A35
PROMPT
PROMPT === DDS 1. End user context ===
SELECT ORA_END_USER_CONTEXT.username AS end_user_name
FROM dual;
PROMPT
PROMPT === DDS 2. Protected view row count ===
SELECT COUNT(*) AS rows_visible
FROM admin.cb_dds_v_search_documents;
PROMPT
PROMPT === DDS 3. Protected view sample ===
SELECT doc_id, title, owner_emp_no, dept_code, contents
FROM admin.cb_dds_v_search_documents
ORDER BY doc_id;
PROMPT
PROMPT === DDS 4. Bypass attempts: no DATA GRANT on base/VPD objects ===
SELECT COUNT(*) FROM admin.cb_dds_documents;
SELECT COUNT(*) FROM admin.cb_search_documents;
SELECT COUNT(*) FROM admin.cb_v_search_documents;
EXIT;

View File

@@ -0,0 +1,25 @@
-- ============================================================
-- 21_agent_ords_security_ords_enable_schema.sql
-- Enable the CB_ORDS schema for ORDS module/handler definitions.
--
-- Run as ADMIN after CB_ORDS has been created.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Enabling CB_ORDS as an ORDS schema ===
BEGIN
ORDS.ENABLE_SCHEMA(
p_enabled => TRUE,
p_schema => 'CB_ORDS',
p_url_mapping_type => 'BASE_PATH',
p_url_mapping_pattern => 'cb-ords',
p_auto_rest_auth => FALSE
);
COMMIT;
END;
/
PROMPT === CB_ORDS ORDS schema enabled ===
EXIT;

View File

@@ -0,0 +1,44 @@
-- ============================================================
-- 23_agent_ords_security_ords_handler_test.sql
-- Run as CB_ORDS.
--
-- This directly executes the same package that the ORDS handlers
-- call. It proves the database-side behavior without requiring an
-- external ORDS URL.
-- ============================================================
WHENEVER SQLERROR CONTINUE
SET ECHO OFF
SET FEEDBACK ON
SET LONG 100000
SET LONGCHUNKSIZE 100000
SET LINESIZE 220
SET PAGESIZE 100
SET SERVEROUTPUT ON
COLUMN response FORMAT A180 WORD_WRAPPED
PROMPT
PROMPT === ORDS handler probe 1. VPD path with mandatory Bearer header ===
SELECT cb_ords_handler_pkg.vpd_search_json('Bearer cb_hr_key') AS response
FROM dual;
PROMPT
PROMPT === ORDS handler probe 2. VPD path without Bearer header ===
BEGIN
DBMS_OUTPUT.PUT_LINE(cb_ords_handler_pkg.vpd_search_json(NULL));
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE(SQLERRM);
END;
/
PROMPT
PROMPT === ORDS handler probe 3. DDS path with Bearer header only ===
SELECT cb_ords_handler_pkg.dds_bearer_probe_json('Bearer cb_hr_key') AS response
FROM dual;
PROMPT
PROMPT === ORDS handler probe 4. DDS path with all-access Bearer header only ===
SELECT cb_ords_handler_pkg.dds_bearer_probe_json('Bearer cb_all_key') AS response
FROM dual;
EXIT;

View File

@@ -0,0 +1,99 @@
-- ============================================================
-- 24_agent_ords_security_inventory.sql
-- Central security inventory for Agent ORDS security.
--
-- Run as ADMIN after VPD, DDS, and ORDS handler setup.
-- ============================================================
WHENEVER SQLERROR CONTINUE
SET ECHO OFF
SET FEEDBACK ON
SET LINESIZE 260
SET PAGESIZE 200
COLUMN object_owner FORMAT A12
COLUMN object_name FORMAT A32
COLUMN policy_name FORMAT A26
COLUMN pf_owner FORMAT A12
COLUMN function FORMAT A30
COLUMN sel FORMAT A5
COLUMN ins FORMAT A5
COLUMN upd FORMAT A5
COLUMN del FORMAT A5
COLUMN enable FORMAT A8
COLUMN expression FORMAT A70 WORD_WRAPPED
COLUMN grant_name FORMAT A24
COLUMN grantee FORMAT A28
COLUMN privilege FORMAT A10
COLUMN allowed_columns FORMAT A76 WORD_WRAPPED
COLUMN excluded_columns FORMAT A22
COLUMN predicate FORMAT A28
COLUMN end_user_name FORMAT A26
COLUMN data_role FORMAT A28
PROMPT
PROMPT === Inventory 1. VPD policies attached to protected objects ===
SELECT object_owner,
object_name,
policy_name,
pf_owner,
function,
sel,
ins,
upd,
del,
enable
FROM dba_policies
WHERE object_owner = 'ADMIN'
AND object_name LIKE 'CB%'
ORDER BY object_name, policy_name;
PROMPT
PROMPT === Inventory 2. Redaction policies attached to protected objects ===
SELECT object_owner,
object_name,
policy_name,
enable,
expression
FROM redaction_policies
WHERE object_owner = 'ADMIN'
AND object_name LIKE 'CB%'
ORDER BY object_name, policy_name;
PROMPT
PROMPT === Inventory 3. DDS grant summary, one row per DATA GRANT ===
SELECT grant_name,
object_name,
grantee,
privilege,
COALESCE(
LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name),
'ALL COLUMNS'
) AS allowed_columns,
COALESCE(MAX(granted_with_all_columns_except), '-') AS excluded_columns,
predicate
FROM dba_data_grants
WHERE owner = 'ADMIN'
AND object_name LIKE 'CB%'
GROUP BY grant_name, object_name, grantee, privilege, predicate
ORDER BY object_name, grant_name;
PROMPT
PROMPT === Inventory 4. DDS end user -> data role -> data grant matrix ===
SELECT rg.grantee AS end_user_name,
rg.data_role,
dg.grant_name,
dg.object_name,
dg.predicate,
COALESCE(MAX(dg.granted_with_all_columns_except), '-') AS excluded_columns
FROM dba_data_role_grants rg
LEFT JOIN dba_data_grants dg
ON dg.grantee = rg.data_role
WHERE rg.grantee LIKE 'cb\_dds\_%' ESCAPE '\'
GROUP BY rg.grantee,
rg.data_role,
dg.grant_name,
dg.object_name,
dg.predicate
ORDER BY rg.grantee, dg.object_name, dg.grant_name;
EXIT;

View File

@@ -0,0 +1,24 @@
-- DDS row/column verification fixtures. Run as ADMIN after 31 and 34.
CREATE TABLE admin.cb_dds_security_fixture (
fixture_id NUMBER PRIMARY KEY,
dept_code VARCHAR2(30) NOT NULL,
owner_emp_no VARCHAR2(30) NOT NULL,
tech_tag VARCHAR2(100) NOT NULL,
title VARCHAR2(200) NOT NULL,
contents VARCHAR2(2000) NOT NULL,
public_summary VARCHAR2(500) NOT NULL
);
INSERT INTO admin.cb_dds_security_fixture VALUES (1,'SALES','E2001','SALES','영업 파이프라인','영업 원문','영업 요약');
INSERT INTO admin.cb_dds_security_fixture VALUES (2,'HR','E10234','HR','인사 운영','인사 원문','인사 요약');
INSERT INTO admin.cb_dds_security_fixture VALUES (3,'FIN','E99999','FINANCE','재무 마감','재무 원문','재무 요약');
COMMIT;
CREATE OR REPLACE DATA GRANT admin.dds_demo_token_fixture_grant
AS SELECT (fixture_id, dept_code, owner_emp_no, tech_tag, title, public_summary)
ON admin.cb_dds_security_fixture
WHERE admin.cb_dds_vector_tag_allowed(tech_tag) = 1
TO cb_dds_token_role;
-- Expected: token mapped to SALES sees row 1; HR/FIN rows are denied.
-- CONTENTS is deliberately excluded: this is the column-level deny case.

View File

@@ -0,0 +1,32 @@
-- DDS execution evidence: durable FGA audit for the protected vector view.
-- Run as ADMIN/AUDIT_ADMIN. In unified auditing the records are queried from
-- UNIFIED_AUDIT_TRAIL with AUDIT_TYPE = 'FineGrainedAudit'.
BEGIN
DBMS_FGA.DROP_POLICY(
object_schema => 'ADMIN', object_name => 'CB_DDS_VECTOR_SEARCH_DOCUMENTS',
policy_name => 'DDS_VECTOR_EXECUTION_EVIDENCE');
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -28102 THEN RAISE; END IF;
END;
/
BEGIN
DBMS_FGA.ADD_POLICY(
object_schema => 'ADMIN',
object_name => 'CB_DDS_VECTOR_SEARCH_DOCUMENTS',
policy_name => 'DDS_VECTOR_EXECUTION_EVIDENCE',
audit_condition => NULL,
audit_column => NULL,
statement_types => 'SELECT',
audit_trail => DBMS_FGA.DB_EXTENDED,
enable => TRUE);
END;
/
-- Evidence lookup (grant AUDIT_VIEWER or expose a definer-rights reader view
-- to the backoffice; never grant this to DDS END USER accounts):
-- SELECT event_timestamp, client_identifier, dbusername, object_name,
-- return_code, sql_text, application_contexts
-- FROM unified_audit_trail
-- WHERE audit_type = 'FineGrainedAudit'
-- AND object_schema = 'ADMIN'
-- AND object_name = 'CB_DDS_VECTOR_SEARCH_DOCUMENTS'
-- ORDER BY event_timestamp DESC;

View File

@@ -432,88 +432,27 @@ BEGIN
END; END;
/ /
PROMPT === 5. Applying column NULL policies from permission display exceptions === PROMPT === 5. Removing deprecated VPD column NULL policies ===
-- ADB rejects application PL/SQL functions inside DBMS_REDACT expressions. -- Architecture rule: VPD applies row predicates only. ASO/Data Redaction
-- SEC_RELEVANT_COL_OPT=ALL_ROWS is the VPD equivalent: it retains the row -- owns all column masking and its user-specific original-value exceptions.
-- but returns NULL for the protected column while the column predicate is false.
CREATE OR REPLACE FUNCTION cb_kb_cust_nm_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CUSTOMERS', 'CUST_NM') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_rrn_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CUSTOMERS', 'RRN_MASKED') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_claim_amt_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CLAIMS', 'CLAIM_AMT') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_paid_amt_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CLAIMS', 'PAID_AMT') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_ext_insurer_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_ext_product_grp_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_ext_product_type_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
DECLARE DECLARE
PROCEDURE replace_column_policy( PROCEDURE drop_column_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS
p_object_name IN VARCHAR2,
p_column_name IN VARCHAR2,
p_policy_name IN VARCHAR2,
p_function_name IN VARCHAR2
) IS
BEGIN BEGIN
BEGIN DBMS_RLS.DROP_POLICY(
DBMS_RLS.DROP_POLICY( object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name
object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name
);
EXCEPTION WHEN OTHERS THEN NULL;
END;
DBMS_RLS.ADD_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name,
function_schema => USER,
policy_function => p_function_name,
statement_types => 'SELECT',
policy_type => DBMS_RLS.DYNAMIC,
sec_relevant_cols => p_column_name,
sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS,
enable => TRUE
); );
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -28102 THEN RAISE; END IF;
END; END;
BEGIN BEGIN
replace_column_policy('KB_CUSTOMERS', 'CUST_NM', 'KB_CUST_NM_CLS_POLICY', 'CB_KB_CUST_NM_CLS'); drop_column_policy('KB_CUSTOMERS', 'KB_CUST_NM_CLS_POLICY');
replace_column_policy('KB_CUSTOMERS', 'RRN_MASKED', 'KB_RRN_CLS_POLICY', 'CB_KB_RRN_CLS'); drop_column_policy('KB_CUSTOMERS', 'KB_RRN_CLS_POLICY');
replace_column_policy('KB_CLAIMS', 'CLAIM_AMT', 'KB_CLAIM_AMT_CLS_POLICY', 'CB_KB_CLAIM_AMT_CLS'); drop_column_policy('KB_CLAIMS', 'KB_CLAIM_AMT_CLS_POLICY');
replace_column_policy('KB_CLAIMS', 'PAID_AMT', 'KB_PAID_AMT_CLS_POLICY', 'CB_KB_PAID_AMT_CLS'); drop_column_policy('KB_CLAIMS', 'KB_PAID_AMT_CLS_POLICY');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER', 'KB_EXT_INSURER_CLS_POLICY', 'CB_KB_EXT_INSURER_CLS'); drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_INSURER_CLS_POLICY');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP', 'KB_EXT_PRODUCT_GRP_CLS_POLICY', 'CB_KB_EXT_PRODUCT_GRP_CLS'); drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_GRP_CLS_POLICY');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY', 'CB_KB_EXT_PRODUCT_TYPE_CLS'); drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY');
END; END;
/ /

View File

@@ -65,13 +65,6 @@ BEGIN
require_runtime_object('CB_AGENT_CTX_PKG', 'PACKAGE BODY'); require_runtime_object('CB_AGENT_CTX_PKG', 'PACKAGE BODY');
require_runtime_object('CB_AGENT_DOC_VPD_FILTER', 'FUNCTION'); require_runtime_object('CB_AGENT_DOC_VPD_FILTER', 'FUNCTION');
require_runtime_object('CB_AGENT_CAN_READ_COLUMN', 'FUNCTION'); require_runtime_object('CB_AGENT_CAN_READ_COLUMN', 'FUNCTION');
require_runtime_object('CB_KB_CUST_NM_CLS', 'FUNCTION');
require_runtime_object('CB_KB_RRN_CLS', 'FUNCTION');
require_runtime_object('CB_KB_CLAIM_AMT_CLS', 'FUNCTION');
require_runtime_object('CB_KB_PAID_AMT_CLS', 'FUNCTION');
require_runtime_object('CB_KB_EXT_INSURER_CLS', 'FUNCTION');
require_runtime_object('CB_KB_EXT_PRODUCT_GRP_CLS', 'FUNCTION');
require_runtime_object('CB_KB_EXT_PRODUCT_TYPE_CLS', 'FUNCTION');
END; END;
/ /
@@ -128,50 +121,28 @@ BEGIN
END; END;
/ /
PROMPT === 4. Reapplying column NULL policies === PROMPT === 4. Removing deprecated VPD column NULL policies ===
-- Architecture rule: VPD filters rows only. Column masking is handled solely
-- by ASO / DBMS_REDACT through 63_kb_aso_masking_rule_runtime.sql.
DECLARE DECLARE
PROCEDURE replace_column_policy( PROCEDURE drop_column_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS
p_object_name IN VARCHAR2,
p_column_name IN VARCHAR2,
p_policy_name IN VARCHAR2,
p_function_name IN VARCHAR2
) IS
v_count NUMBER;
BEGIN BEGIN
SELECT COUNT(*) DBMS_RLS.DROP_POLICY(
INTO v_count object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name
FROM all_policies
WHERE object_owner = 'POC_2'
AND object_name = p_object_name
AND policy_name = p_policy_name;
IF v_count > 0 THEN
DBMS_RLS.DROP_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name
);
END IF;
DBMS_RLS.ADD_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name,
function_schema => 'ADMIN',
policy_function => p_function_name,
statement_types => 'SELECT',
policy_type => DBMS_RLS.DYNAMIC,
sec_relevant_cols => p_column_name,
sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS,
enable => TRUE
); );
EXCEPTION
WHEN OTHERS THEN
-- A missing policy is already the desired state. Do not hide other errors.
IF SQLCODE != -28102 THEN RAISE; END IF;
END; END;
BEGIN BEGIN
replace_column_policy('KB_CUSTOMERS', 'CUST_NM', 'KB_CUST_NM_CLS_POLICY', 'CB_KB_CUST_NM_CLS'); drop_column_policy('KB_CUSTOMERS', 'KB_CUST_NM_CLS_POLICY');
replace_column_policy('KB_CUSTOMERS', 'RRN_MASKED', 'KB_RRN_CLS_POLICY', 'CB_KB_RRN_CLS'); drop_column_policy('KB_CUSTOMERS', 'KB_RRN_CLS_POLICY');
replace_column_policy('KB_CLAIMS', 'CLAIM_AMT', 'KB_CLAIM_AMT_CLS_POLICY', 'CB_KB_CLAIM_AMT_CLS'); drop_column_policy('KB_CLAIMS', 'KB_CLAIM_AMT_CLS_POLICY');
replace_column_policy('KB_CLAIMS', 'PAID_AMT', 'KB_PAID_AMT_CLS_POLICY', 'CB_KB_PAID_AMT_CLS'); drop_column_policy('KB_CLAIMS', 'KB_PAID_AMT_CLS_POLICY');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER', 'KB_EXT_INSURER_CLS_POLICY', 'CB_KB_EXT_INSURER_CLS'); drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_INSURER_CLS_POLICY');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP', 'KB_EXT_PRODUCT_GRP_CLS_POLICY', 'CB_KB_EXT_PRODUCT_GRP_CLS'); drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_GRP_CLS_POLICY');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY', 'CB_KB_EXT_PRODUCT_TYPE_CLS'); drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY');
END; END;
/ /

View File

@@ -0,0 +1,100 @@
-- ============================================================
-- 62_kb_aso_masking_backoffice_metadata.sql
--
-- Creates the metadata used by the masking-rule backoffice pages.
-- Run as ADMIN with SQLcl. This script does not create a DBMS_REDACT
-- policy; run 63_kb_aso_masking_rule_runtime.sql after rules are
-- connected to protected columns.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Creating masking-rule metadata tables ===
DECLARE
PROCEDURE ensure_table(p_name IN VARCHAR2, p_ddl IN CLOB) IS
BEGIN
EXECUTE IMMEDIATE p_ddl;
DBMS_OUTPUT.PUT_LINE('created ' || p_name);
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE = -955 THEN
DBMS_OUTPUT.PUT_LINE('exists ' || p_name);
ELSE
RAISE;
END IF;
END;
BEGIN
ensure_table('CB_MASKING_RULE', q'[
CREATE TABLE cb_masking_rule (
rule_id NUMBER PRIMARY KEY,
rule_code VARCHAR2(64) NOT NULL UNIQUE,
rule_name VARCHAR2(100) NOT NULL,
template_code VARCHAR2(30) NOT NULL,
description VARCHAR2(400),
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
)]');
ensure_table('CB_COLUMN_MASKING_RULE', q'[
CREATE TABLE cb_column_masking_rule (
column_id NUMBER PRIMARY KEY,
rule_id NUMBER NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)]');
ensure_table('CB_USER_MASKING_RULE', q'[
CREATE TABLE cb_user_masking_rule (
user_id NUMBER NOT NULL,
column_id NUMBER NOT NULL,
decision VARCHAR2(10) NOT NULL CHECK (decision IN ('MASK','UNMASK')),
active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT cb_user_masking_rule_pk PRIMARY KEY (user_id, column_id)
)]');
END;
/
PROMPT === Seeding curated masking templates ===
DECLARE
v_next_rule_id NUMBER;
PROCEDURE ensure_rule(
p_code IN VARCHAR2,
p_name IN VARCHAR2,
p_template IN VARCHAR2,
p_description IN VARCHAR2
) IS
BEGIN
MERGE INTO cb_masking_rule dst
USING (
SELECT p_code rule_code,
p_name rule_name,
p_template template_code,
p_description description
FROM dual
) src
ON (dst.rule_code = src.rule_code)
WHEN NOT MATCHED THEN
INSERT (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
VALUES (v_next_rule_id, src.rule_code, src.rule_name, src.template_code, src.description, 'Y');
v_next_rule_id := v_next_rule_id + 1;
END;
BEGIN
SELECT NVL(MAX(rule_id), 0) + 1 INTO v_next_rule_id FROM cb_masking_rule;
ensure_rule('MASK_NULLIFY', '값 숨김 (NULL)', 'NULLIFY',
'값을 NULL로 반환하는 기본 마스킹 방식');
ensure_rule('MASK_FULL', '전체 마스킹', 'FULL',
'문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식');
ensure_rule('MASK_TEXT_PARTIAL', '문자열 일부 마스킹', 'TEXT_PARTIAL',
'첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식');
ensure_rule('MASK_RRN_PARTIAL', '주민등록번호 부분 마스킹', 'RRN_PARTIAL',
'앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식');
END;
/
COMMIT;
PROMPT === Metadata ready ===
SELECT rule_id, rule_code, rule_name, template_code, enabled_yn
FROM cb_masking_rule
ORDER BY rule_id;
EXIT

View File

@@ -0,0 +1,679 @@
-- ============================================================
-- 63_kb_aso_masking_rule_runtime.sql
--
-- Synchronizes CB_MASKING_RULE / CB_COLUMN_MASKING_RULE and
-- CB_USER_MASKING_RULE into Oracle Data Redaction policies.
--
-- Important behaviour
-- * One selected masking rule is applied to each protected column.
-- * A direct user rule controls whether that user is masked or receives
-- an original-value exception (UNMASK).
-- * VPD still determines row visibility. Existing VPD NULL policies
-- remain in force for users without column permission.
--
-- Run as ADMIN with SQLcl after 62 to initialize the runtime package, or as
-- a manual recovery/reconciliation action. The backoffice synchronizes
-- DBMS_REDACT on normal configuration changes; this script follows the same
-- source-of-truth rule for existing installations.
-- Requires EXECUTE on DBMS_REDACT and ADMINISTER REDACTION POLICY for POC_2
-- objects.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Validating masking-rule metadata ===
DECLARE
PROCEDURE require_table(p_table_name IN VARCHAR2) IS
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM user_tables
WHERE table_name = p_table_name;
IF v_count = 0 THEN
RAISE_APPLICATION_ERROR(-20001,
'Missing ' || p_table_name || '. Run 62_kb_aso_masking_backoffice_metadata.sql first.');
END IF;
END;
BEGIN
require_table('CB_MASKING_RULE');
require_table('CB_COLUMN_MASKING_RULE');
require_table('CB_USER_MASKING_RULE');
END;
/
PROMPT === Updating trusted bearer context with column exception flags ===
CREATE OR REPLACE PACKAGE cb_agent_ctx_pkg AUTHID DEFINER AS
PROCEDURE clear_user;
PROCEDURE set_user(p_user_id IN NUMBER);
PROCEDURE set_user_values(
p_user_id IN NUMBER,
p_emp_no IN VARCHAR2,
p_dept_code IN VARCHAR2,
p_can_read_contents IN VARCHAR2
);
PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2);
END;
/
CREATE OR REPLACE PACKAGE BODY cb_agent_ctx_pkg AS
PROCEDURE clear_stakeholder_context AS
BEGIN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL', NULL);
END;
PROCEDURE clear_masking_rule_context AS
BEGIN
FOR r IN (SELECT column_id FROM cb_column_masking_rule) LOOP
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'MR_' || TO_CHAR(r.column_id), NULL);
END LOOP;
END;
PROCEDURE set_masking_rule_context(p_user_id IN NUMBER) AS
BEGIN
clear_masking_rule_context;
FOR r IN (
SELECT link.column_id,
CASE WHEN EXISTS (
SELECT 1
FROM cb_user_masking_rule user_rule
WHERE user_rule.user_id = p_user_id
AND user_rule.column_id = link.column_id
AND user_rule.active_yn = 'Y'
AND user_rule.decision = 'UNMASK'
) THEN 'Y' ELSE 'N' END AS can_unmask
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
WHERE rule.enabled_yn = 'Y'
) LOOP
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'MR_' || TO_CHAR(r.column_id), r.can_unmask);
END LOOP;
END;
PROCEDURE clear_user AS
BEGIN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'USER_ID', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'EMP_NO', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'CAN_READ_CONTENTS', NULL);
clear_stakeholder_context;
clear_masking_rule_context;
END;
PROCEDURE set_user_values(
p_user_id IN NUMBER,
p_emp_no IN VARCHAR2,
p_dept_code IN VARCHAR2,
p_can_read_contents IN VARCHAR2
) AS
BEGIN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'USER_ID', TO_CHAR(p_user_id));
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'EMP_NO', p_emp_no);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE', p_dept_code);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'CAN_READ_CONTENTS', p_can_read_contents);
clear_stakeholder_context;
clear_masking_rule_context;
END;
PROCEDURE set_stakeholder_context(
p_stakeholder_user_id IN VARCHAR2,
p_role IN VARCHAR2,
p_channel IN VARCHAR2
) AS
v_channel VARCHAR2(50);
BEGIN
v_channel := CASE TRIM(p_channel)
WHEN '설계사채널' THEN '설계사'
WHEN 'GA채널' THEN 'GA'
ELSE TRIM(p_channel)
END;
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', p_stakeholder_user_id);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE', p_role);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL', v_channel);
END;
PROCEDURE set_user(p_user_id IN NUMBER) AS
v_emp_no cb_app_user.employee_no%TYPE;
v_dept_code cb_app_user.dept_code%TYPE;
v_can_read_contents cb_app_user.can_read_contents%TYPE;
BEGIN
SELECT employee_no,
dept_code,
CASE
WHEN EXISTS (
SELECT 1
FROM cb_user_role ur
JOIN cb_permission p ON p.role_id = ur.role_id
JOIN cb_permission_column pc ON pc.permission_id = p.perm_id
WHERE ur.user_id = p_user_id
AND p.target_name = 'CB_V_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND pc.column_name = 'CONTENTS'
) THEN 'Y'
ELSE 'N'
END
INTO v_emp_no, v_dept_code, v_can_read_contents
FROM cb_app_user
WHERE user_id = p_user_id
AND active = 'Y';
set_user_values(p_user_id, v_emp_no, v_dept_code, v_can_read_contents);
set_masking_rule_context(p_user_id);
EXCEPTION
WHEN NO_DATA_FOUND THEN
clear_user;
RAISE_APPLICATION_ERROR(-20003, 'Mapped application user not found');
END;
PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2) AS
v_user_id cb_app_user.user_id%TYPE;
v_emp_no cb_app_user.employee_no%TYPE;
v_dept_code cb_app_user.dept_code%TYPE;
v_can_read_contents cb_app_user.can_read_contents%TYPE;
v_stakeholder_user_id VARCHAR2(30);
v_stakeholder_role VARCHAR2(50);
v_stakeholder_channel VARCHAR2(50);
BEGIN
IF p_bearer_key IS NULL THEN
clear_user;
RAISE_APPLICATION_ERROR(-20001, 'Authorization Bearer key is required');
END IF;
SELECT u.user_id,
u.employee_no,
u.dept_code,
CASE
WHEN EXISTS (
SELECT 1
FROM cb_user_role ur
JOIN cb_permission p ON p.role_id = ur.role_id
JOIN cb_permission_column pc ON pc.permission_id = p.perm_id
WHERE ur.user_id = u.user_id
AND p.target_name = 'CB_V_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND pc.column_name = 'CONTENTS'
) THEN 'Y'
ELSE 'N'
END,
COALESCE(k.stakeholder_user_id, u.stakeholder_user_id)
INTO v_user_id, v_emp_no, v_dept_code, v_can_read_contents, v_stakeholder_user_id
FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id
WHERE k.key_hash = STANDARD_HASH(p_bearer_key, 'SHA256')
AND k.active = 'Y'
AND k.revoked_at IS NULL
AND (k.expires_at IS NULL OR k.expires_at > SYSDATE)
AND u.active = 'Y';
set_user_values(v_user_id, v_emp_no, v_dept_code, v_can_read_contents);
IF v_stakeholder_user_id IS NOT NULL THEN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', v_stakeholder_user_id);
SELECT role, channel
INTO v_stakeholder_role, v_stakeholder_channel
FROM poc_2.kb_stakeholders
WHERE user_id = v_stakeholder_user_id;
set_stakeholder_context(v_stakeholder_user_id, v_stakeholder_role, v_stakeholder_channel);
END IF;
set_masking_rule_context(v_user_id);
EXCEPTION
WHEN NO_DATA_FOUND THEN
clear_user;
RAISE_APPLICATION_ERROR(-20002, 'Invalid or expired Bearer key');
END;
END;
/
SHOW ERRORS PACKAGE BODY cb_agent_ctx_pkg
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
PROMPT === Preserving the all-access admin raw-display contract ===
MERGE INTO cb_user_masking_rule dst
USING (
SELECT user_row.user_id, column_rule.column_id
FROM cb_app_user user_row
CROSS JOIN cb_column_masking_rule column_rule
JOIN cb_masking_rule rule ON rule.rule_id = column_rule.rule_id
WHERE user_row.stakeholder_user_id = 'KB_VPD_ADMIN'
AND user_row.active = 'Y'
AND rule.enabled_yn = 'Y'
) src
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
WHEN NOT MATCHED THEN
INSERT (user_id, column_id, decision, active_yn, updated_at)
VALUES (src.user_id, src.column_id, 'UNMASK', 'Y', SYSTIMESTAMP);
COMMIT;
PROMPT === Removing stale columns and disabling unconfigured KB policies ===
-- A policy definition is deliberately retained when no column is configured.
-- DISABLE_POLICY makes the effective state match the empty backoffice setting
-- and lets a later column assignment re-enable the same named policy safely.
DECLARE
PROCEDURE reconcile_existing_policy(
p_object_name IN VARCHAR2,
p_policy_name IN VARCHAR2
) IS
v_policy_count NUMBER;
v_configured_count NUMBER;
BEGIN
SELECT COUNT(*) INTO v_policy_count
FROM redaction_policies
WHERE object_owner = 'POC_2' AND object_name = p_object_name AND policy_name = p_policy_name;
IF v_policy_count = 0 THEN
RETURN;
END IF;
SELECT COUNT(*) INTO v_configured_count
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column column_row ON column_row.column_id = link.column_id
JOIN cb_protected_object object_row ON object_row.object_id = column_row.object_id
WHERE object_row.owner = 'POC_2'
AND object_row.object_name = p_object_name
AND rule.enabled_yn = 'Y';
IF v_configured_count = 0 THEN
DBMS_REDACT.DISABLE_POLICY('POC_2', p_object_name, p_policy_name);
RETURN;
END IF;
FOR stale_column IN (
SELECT policy_column.column_name
FROM redaction_columns policy_column
WHERE policy_column.object_owner = 'POC_2'
AND policy_column.object_name = p_object_name
AND NOT EXISTS (
SELECT 1
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column column_row ON column_row.column_id = link.column_id
JOIN cb_protected_object object_row ON object_row.object_id = column_row.object_id
WHERE object_row.owner = 'POC_2'
AND object_row.object_name = p_object_name
AND column_row.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y'
)
) LOOP
DBMS_REDACT.ALTER_POLICY(
object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name,
action => DBMS_REDACT.DROP_COLUMN, column_name => stale_column.column_name
);
END LOOP;
END;
BEGIN
reconcile_existing_policy('KB_CUSTOMERS', 'KB_CUSTOMER_PII_REDACT');
reconcile_existing_policy('KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT');
reconcile_existing_policy('KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT');
reconcile_existing_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT');
END;
/
PROMPT === Integrating configured columns into the existing KB Data Redaction policies ===
DECLARE
v_policy_name VARCHAR2(128);
v_expression_name VARCHAR2(128);
v_expression VARCHAR2(4000);
PROCEDURE require_existing_policy(
p_object_name IN VARCHAR2,
p_policy_name IN VARCHAR2
) IS
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM redaction_policies
WHERE object_owner = 'POC_2'
AND object_name = p_object_name
AND policy_name = p_policy_name;
IF v_count = 0 THEN
RAISE_APPLICATION_ERROR(-20012,
'Existing Data Redaction policy not found: POC_2.' || p_object_name || '.' || p_policy_name);
END IF;
END;
PROCEDURE upsert_expression(
p_expression_name IN VARCHAR2,
p_expression IN VARCHAR2
) IS
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM redaction_expressions
WHERE policy_expression_name = p_expression_name;
IF v_count = 0 THEN
DBMS_REDACT.CREATE_POLICY_EXPRESSION(
policy_expression_name => p_expression_name,
expression => p_expression,
policy_expression_description => 'Mask unless trusted bearer context grants an original-value exception'
);
ELSE
DBMS_REDACT.UPDATE_POLICY_EXPRESSION(
policy_expression_name => p_expression_name,
expression => p_expression,
policy_expression_description => 'Mask unless trusted bearer context grants an original-value exception'
);
END IF;
END;
PROCEDURE modify_column(
p_object_name IN VARCHAR2,
p_policy_name IN VARCHAR2,
p_column_name IN VARCHAR2,
p_template_code IN VARCHAR2
) IS
PROCEDURE apply(
p_function_type IN BINARY_INTEGER,
p_pattern IN VARCHAR2 DEFAULT NULL,
p_replace IN VARCHAR2 DEFAULT NULL
) IS
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name,
action => DBMS_REDACT.MODIFY_COLUMN,
column_name => p_column_name,
function_type => p_function_type,
regexp_pattern => p_pattern,
regexp_replace_string => p_replace
);
END;
BEGIN
CASE p_template_code
WHEN 'NULLIFY' THEN
apply(DBMS_REDACT.NULLIFY);
WHEN 'FULL' THEN
apply(DBMS_REDACT.FULL);
WHEN 'TEXT_PARTIAL' THEN
apply(DBMS_REDACT.REGEXP, '(^.).*$', '\1***');
WHEN 'RRN_PARTIAL' THEN
apply(DBMS_REDACT.REGEXP, '(^[0-9]{6})-?[0-9]{7}$', '\1-*******');
ELSE
RAISE_APPLICATION_ERROR(-20013, 'Unsupported masking template: ' || p_template_code);
END CASE;
END;
BEGIN
FOR configured_column IN (
SELECT protected_object.object_name,
protected_column.column_id,
protected_column.column_name,
masking_rule.template_code
FROM cb_column_masking_rule link
JOIN cb_masking_rule masking_rule ON masking_rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
AND masking_rule.enabled_yn = 'Y'
ORDER BY protected_object.object_name, protected_column.column_id
) LOOP
v_policy_name := CASE configured_column.object_name
WHEN 'KB_CUSTOMERS' THEN 'KB_CUSTOMER_PII_REDACT'
WHEN 'KB_CLAIMS' THEN 'KB_CLAIM_AMOUNT_REDACT'
WHEN 'KB_CONTRACTS' THEN 'KB_CONTRACT_PREMIUM_REDACT'
WHEN 'KB_EXTERNAL_HOLDINGS' THEN 'KB_EXT_HOLDING_REDACT'
ELSE NULL
END;
IF v_policy_name IS NULL THEN
RAISE_APPLICATION_ERROR(-20014,
'No existing KB Data Redaction policy mapping for ' || configured_column.object_name);
END IF;
require_existing_policy(configured_column.object_name, v_policy_name);
DBMS_REDACT.ENABLE_POLICY('POC_2', configured_column.object_name, v_policy_name);
modify_column(
configured_column.object_name,
v_policy_name,
configured_column.column_name,
configured_column.template_code
);
v_expression_name := 'CBMR_' || TO_CHAR(configured_column.column_id);
v_expression := 'SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(configured_column.column_id)
|| ''') IS NULL OR SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(configured_column.column_id) || ''') <> ''Y''';
upsert_expression(v_expression_name, v_expression);
DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL(
object_schema => 'POC_2',
object_name => configured_column.object_name,
column_name => configured_column.column_name,
policy_expression_name => v_expression_name
);
END LOOP;
END;
/
COMMIT;
PROMPT === Verification ===
SELECT object_name, policy_name, expression
FROM redaction_policies
WHERE object_owner = 'POC_2'
AND object_name IN ('KB_CUSTOMERS', 'KB_CLAIMS', 'KB_CONTRACTS', 'KB_EXTERNAL_HOLDINGS')
ORDER BY object_name, policy_name;
SELECT object_name, column_name, function_type
FROM redaction_columns
WHERE object_owner = 'POC_2'
AND object_name IN ('KB_CUSTOMERS', 'KB_CLAIMS', 'KB_CONTRACTS', 'KB_EXTERNAL_HOLDINGS')
ORDER BY object_name, column_name;
SELECT policy_expression_name, object_name, column_name, expression
FROM redaction_expressions
WHERE policy_expression_name LIKE 'CBMR_%'
ORDER BY policy_expression_name;
SELECT user_row.user_name,
protected_object.object_name,
protected_column.column_name,
user_rule.decision
FROM cb_user_masking_rule user_rule
JOIN cb_app_user user_row ON user_row.user_id = user_rule.user_id
JOIN cb_protected_column protected_column ON protected_column.column_id = user_rule.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
ORDER BY user_row.user_name, protected_object.object_name, protected_column.column_name;
EXIT
-- Legacy replacement implementation retained below for migration reference.
-- SQLcl exits above, so it never executes.
PROMPT === Rebuilding managed Oracle Data Redaction policies ===
DECLARE
v_policy_name VARCHAR2(128);
v_expression_name VARCHAR2(128);
v_first BOOLEAN;
PROCEDURE apply_column(
p_first IN BOOLEAN,
p_object_name IN VARCHAR2,
p_policy_name IN VARCHAR2,
p_column_name IN VARCHAR2,
p_template_code IN VARCHAR2
) IS
PROCEDURE first_policy(
p_function_type IN BINARY_INTEGER,
p_pattern IN VARCHAR2 DEFAULT NULL,
p_replace IN VARCHAR2 DEFAULT NULL
) IS
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name,
policy_description => 'Managed by CB masking rule backoffice',
column_name => p_column_name,
function_type => p_function_type,
expression => '1=1',
regexp_pattern => p_pattern,
regexp_replace_string => p_replace,
enable => TRUE
);
END;
PROCEDURE additional_column(
p_function_type IN BINARY_INTEGER,
p_pattern IN VARCHAR2 DEFAULT NULL,
p_replace IN VARCHAR2 DEFAULT NULL
) IS
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name,
action => DBMS_REDACT.ADD_COLUMN,
column_name => p_column_name,
function_type => p_function_type,
regexp_pattern => p_pattern,
regexp_replace_string => p_replace
);
END;
PROCEDURE invoke(
p_function_type IN BINARY_INTEGER,
p_pattern IN VARCHAR2 DEFAULT NULL,
p_replace IN VARCHAR2 DEFAULT NULL
) IS
BEGIN
IF p_first THEN
first_policy(p_function_type, p_pattern, p_replace);
ELSE
additional_column(p_function_type, p_pattern, p_replace);
END IF;
END;
BEGIN
CASE p_template_code
WHEN 'NULLIFY' THEN
invoke(DBMS_REDACT.NULLIFY);
WHEN 'FULL' THEN
invoke(DBMS_REDACT.FULL);
WHEN 'TEXT_PARTIAL' THEN
invoke(DBMS_REDACT.REGEXP, '(^.).*$', '\1***');
WHEN 'RRN_PARTIAL' THEN
invoke(DBMS_REDACT.REGEXP, '(^[0-9]{6})-?[0-9]{7}$', '\1-*******');
ELSE
RAISE_APPLICATION_ERROR(-20011, 'Unsupported template: ' || p_template_code);
END CASE;
END;
PROCEDURE drop_managed_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS
BEGIN
DBMS_REDACT.DROP_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name
);
EXCEPTION
WHEN OTHERS THEN
NULL;
END;
PROCEDURE drop_expression(p_expression_name IN VARCHAR2) IS
BEGIN
DBMS_REDACT.DROP_POLICY_EXPRESSION(p_expression_name);
EXCEPTION
WHEN OTHERS THEN
NULL;
END;
BEGIN
-- These three policies are legacy test policies found on the KB objects.
-- Data Redaction permits only one policy per object, so replace them with
-- the managed policy rather than leaving their client-identifier tests live.
drop_managed_policy('KB_CUSTOMERS', 'KB_CUSTOMER_PII_REDACT');
drop_managed_policy('KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT');
drop_managed_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT');
FOR object_row IN (
SELECT DISTINCT po.object_name
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column pc ON pc.column_id = link.column_id
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE rule.enabled_yn = 'Y'
AND po.owner = 'POC_2'
) LOOP
v_policy_name := 'CB_ASO_' || object_row.object_name || '_MASK';
drop_managed_policy(object_row.object_name, v_policy_name);
END LOOP;
FOR column_row IN (
SELECT link.column_id
FROM cb_column_masking_rule link
) LOOP
drop_expression('CBMR_' || TO_CHAR(column_row.column_id));
END LOOP;
FOR object_row IN (
SELECT DISTINCT po.object_name
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column pc ON pc.column_id = link.column_id
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE rule.enabled_yn = 'Y'
AND po.owner = 'POC_2'
ORDER BY po.object_name
) LOOP
v_policy_name := 'CB_ASO_' || object_row.object_name || '_MASK';
v_first := TRUE;
FOR column_row IN (
SELECT pc.column_id,
pc.column_name,
rule.template_code,
rule.rule_name
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column pc ON pc.column_id = link.column_id
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE po.owner = 'POC_2'
AND po.object_name = object_row.object_name
AND rule.enabled_yn = 'Y'
ORDER BY pc.column_id
) LOOP
apply_column(v_first, object_row.object_name, v_policy_name,
column_row.column_name, column_row.template_code);
v_expression_name := 'CBMR_' || TO_CHAR(column_row.column_id);
DBMS_REDACT.CREATE_POLICY_EXPRESSION(
policy_expression_name => v_expression_name,
expression => 'SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(column_row.column_id)
|| ''') IS NULL OR SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(column_row.column_id) || ''') <> ''Y''',
policy_expression_description => 'Mask unless trusted bearer context grants an original-value exception'
);
DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL(
object_schema => 'POC_2',
object_name => object_row.object_name,
column_name => column_row.column_name,
policy_expression_name => v_expression_name
);
v_first := FALSE;
END LOOP;
END LOOP;
END;
/
COMMIT;
PROMPT === Verification ===
SELECT object_name, policy_name, expression
FROM redaction_policies
WHERE object_owner = 'POC_2'
AND policy_name LIKE 'CB_ASO_%'
ORDER BY object_name, policy_name;
SELECT object_name, column_name, function_type
FROM redaction_columns
WHERE object_owner = 'POC_2'
AND object_name IN ('KB_CUSTOMERS', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
ORDER BY object_name, column_name;
SELECT user_row.user_name,
protected_object.object_name,
protected_column.column_name,
user_rule.decision
FROM cb_user_masking_rule user_rule
JOIN cb_app_user user_row ON user_row.user_id = user_rule.user_id
JOIN cb_protected_column protected_column ON protected_column.column_id = user_rule.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
ORDER BY user_row.user_name, protected_object.object_name, protected_column.column_name;
EXIT

View File

@@ -0,0 +1,54 @@
-- ============================================================
-- 64_kb_aso_masking_default_column_rules.sql
--
-- Initial blacklist of KB columns subject to ASO masking.
-- Run as ADMIN after 62. Execute 63 afterwards to apply the
-- configured rules as DBMS_REDACT policies.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Assigning the KB masking target blacklist ===
MERGE INTO cb_column_masking_rule dst
USING (
SELECT protected_column.column_id,
masking_rule.rule_id
FROM cb_protected_column protected_column
JOIN cb_protected_object protected_object
ON protected_object.object_id = protected_column.object_id
JOIN cb_masking_rule masking_rule
ON masking_rule.rule_code = CASE
WHEN protected_object.object_name = 'KB_CUSTOMERS'
AND protected_column.column_name = 'RRN_MASKED'
THEN 'MASK_RRN_PARTIAL'
ELSE 'MASK_NULLIFY'
END
WHERE (protected_object.object_name = 'KB_CUSTOMERS'
AND protected_column.column_name IN ('RRN_MASKED'))
OR (protected_object.object_name = 'KB_CLAIMS'
AND protected_column.column_name IN ('CLAIM_AMT', 'PAID_AMT'))
OR (protected_object.object_name = 'KB_EXTERNAL_HOLDINGS'
AND protected_column.column_name IN ('EXT_INSURER', 'EXT_PRODUCT_GRP', 'EXT_PRODUCT_TYPE'))
) src
ON (dst.column_id = src.column_id)
WHEN MATCHED THEN
UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (column_id, rule_id, updated_at)
VALUES (src.column_id, src.rule_id, SYSTIMESTAMP);
COMMIT;
PROMPT === Configured masking target blacklist ===
SELECT protected_object.owner,
protected_object.object_name,
protected_column.column_name,
masking_rule.rule_code,
masking_rule.rule_name,
masking_rule.template_code
FROM cb_column_masking_rule link
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
JOIN cb_masking_rule masking_rule ON masking_rule.rule_id = link.rule_id
ORDER BY protected_object.object_name, protected_column.column_name;
EXIT

View File

@@ -0,0 +1,221 @@
-- ============================================================
-- 65_kb_select_ai_vpd_query_api.sql
--
-- Natural-language KB query API backing package.
-- Run as POC_2. The caller (CB_ORDS) establishes CB_AGENT_CTX before
-- invoking this package, so generated SQL is still subject to POC_2 VPD.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Annotating KB schema for Select AI ===
COMMENT ON TABLE poc_2.kb_customers IS '고객원장. CUST_ID는 C로 시작하는 고객 식별자 예: C1001006.';
COMMENT ON COLUMN poc_2.kb_customers.cust_id IS '고객 식별자. C로 시작하는 값 예: C1001006.';
COMMENT ON TABLE poc_2.kb_products IS '상품원장 및 약관상품 마스터. PRODUCT_CD는 숫자형 상품/약관 코드 예: 41048.';
COMMENT ON COLUMN poc_2.kb_products.product_cd IS '상품 또는 약관상품 코드. 숫자형 코드 예: 41048. KB_CONTRACTS.PRODUCT_CD와 조인한다.';
COMMENT ON COLUMN poc_2.kb_products.clause_product_nm IS '약관상품명 또는 상품명. 고객 설명용 상품 비교에 사용한다.';
COMMENT ON COLUMN poc_2.kb_products.insurance_type IS '보험 대분류. 예: 자동차, 장기, 일반.';
COMMENT ON COLUMN poc_2.kb_products.product_type IS '상품 유형 또는 세부 상품군.';
COMMENT ON COLUMN poc_2.kb_products.clause_version IS '약관 버전.';
COMMENT ON COLUMN poc_2.kb_products.sale_status IS '판매 상태.';
COMMENT ON COLUMN poc_2.kb_products.active_yn IS '상품 활성 여부.';
COMMENT ON TABLE poc_2.kb_contracts IS '계약원장. CONTRACT_NO는 CT로 시작하는 계약번호이고, PRODUCT_CD는 숫자형 상품/약관 코드다.';
COMMENT ON COLUMN poc_2.kb_contracts.contract_no IS '계약번호. CT로 시작하는 값 예: CT2699001. 41048 같은 순수 숫자 코드는 계약번호가 아니라 PRODUCT_CD일 가능성이 높다.';
COMMENT ON COLUMN poc_2.kb_contracts.cust_id IS '계약 고객 식별자. KB_CUSTOMERS.CUST_ID와 조인한다.';
COMMENT ON COLUMN poc_2.kb_contracts.product_cd IS '계약의 상품/약관 코드. 숫자형 코드 예: 41048. KB_PRODUCTS.PRODUCT_CD와 조인한다.';
COMMENT ON COLUMN poc_2.kb_contracts.fc_id IS '담당 설계사 또는 FC 사용자 ID. 예: FC00789.';
COMMENT ON COLUMN poc_2.kb_contracts.fc_channel IS '담당 채널. 예: 설계사, GA, 다이렉트.';
COMMENT ON COLUMN poc_2.kb_contracts.contract_status IS '계약 상태. 예: 정상, 실효.';
COMMENT ON COLUMN poc_2.kb_contracts.premium IS '계약 보험료.';
COMMENT ON COLUMN poc_2.kb_contracts.pay_cycle IS '보험료 납입 주기.';
COMMENT ON TABLE poc_2.kb_coverages IS '담보원장. 계약번호 CONTRACT_NO 기준으로 KB_CONTRACTS와 조인해 보장/특약을 설명한다.';
COMMENT ON COLUMN poc_2.kb_coverages.contract_no IS '담보가 속한 계약번호. KB_CONTRACTS.CONTRACT_NO와 조인한다.';
COMMENT ON COLUMN poc_2.kb_coverages.cust_id IS '담보 고객 식별자.';
COMMENT ON COLUMN poc_2.kb_coverages.coverage_nm IS '담보명 또는 특약명.';
COMMENT ON COLUMN poc_2.kb_coverages.coverage_type IS '담보 유형.';
COMMENT ON COLUMN poc_2.kb_coverages.coverage_div IS '담보 구분.';
COMMENT ON COLUMN poc_2.kb_coverages.insured_amt IS '가입금액 또는 보장금액.';
COMMENT ON COLUMN poc_2.kb_coverages.renew_due_dt IS '갱신 예정일.';
COMMENT ON TABLE poc_2.kb_external_holdings IS '외부보유정보 원장. 타 보험사 보유계약과 약관상품을 고객 CUST_ID 기준으로 비교한다.';
COMMENT ON COLUMN poc_2.kb_external_holdings.cust_id IS '외부보유정보 고객 식별자. KB_CUSTOMERS.CUST_ID와 조인한다.';
COMMENT ON COLUMN poc_2.kb_external_holdings.ext_insurer IS '외부 보험사명. 예: 삼성화재.';
COMMENT ON COLUMN poc_2.kb_external_holdings.ext_product_grp IS '외부 상품군. 예: 자동차.';
COMMENT ON COLUMN poc_2.kb_external_holdings.ext_product_type IS '외부 상품 유형. 예: 개인용.';
COMMENT ON COLUMN poc_2.kb_external_holdings.ext_clause_nm IS '외부 약관상품명.';
COMMENT ON COLUMN poc_2.kb_external_holdings.ext_sale_status IS '외부 상품 판매 상태.';
COMMENT ON COLUMN poc_2.kb_external_holdings.ext_renew_month IS '외부 보유계약 갱신월.';
PROMPT === Creating VPD-aware Select AI query API ===
CREATE OR REPLACE PACKAGE poc_2.kb_select_ai_vpd_query_api AUTHID DEFINER AS
PROCEDURE open_query(
p_prompt IN CLOB,
p_limit IN PLS_INTEGER,
p_rows OUT SYS_REFCURSOR,
p_generated_sql OUT CLOB
);
END kb_select_ai_vpd_query_api;
/
CREATE OR REPLACE PACKAGE BODY poc_2.kb_select_ai_vpd_query_api AS
c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1';
c_max_prompt_length CONSTANT PLS_INTEGER := 4000;
c_max_rows CONSTANT PLS_INTEGER := 100;
FUNCTION normalized_sql(p_generated_sql IN CLOB) RETURN VARCHAR2 IS
v_sql VARCHAR2(32767);
v_comment_end PLS_INTEGER;
BEGIN
IF p_generated_sql IS NULL OR DBMS_LOB.GETLENGTH(p_generated_sql) = 0 THEN
RAISE_APPLICATION_ERROR(-20811, 'Select AI did not generate SQL.');
END IF;
IF DBMS_LOB.GETLENGTH(p_generated_sql) > 32767 THEN
RAISE_APPLICATION_ERROR(-20812, 'Generated SQL is too long.');
END IF;
v_sql := TRIM(DBMS_LOB.SUBSTR(p_generated_sql, 32767, 1));
-- Select AI sometimes wraps an otherwise valid SQL statement in markdown
-- fences or adds a short leading explanation comment. Strip only those
-- wrapper artifacts before the strict safety validator runs. Comments that
-- remain inside the executable statement are still rejected below.
LOOP
v_sql := TRIM(v_sql);
v_sql := REGEXP_REPLACE(v_sql, '^[[:space:]]*```[[:alpha:]]*[[:space:]]*', '');
v_sql := REGEXP_REPLACE(v_sql, '[[:space:]]*```[[:space:]]*$', '');
v_sql := TRIM(v_sql);
IF SUBSTR(v_sql, 1, 2) = '--' THEN
v_comment_end := INSTR(v_sql, CHR(10));
IF v_comment_end = 0 THEN
RAISE_APPLICATION_ERROR(-20813, 'Select AI returned only a comment, not SQL.');
END IF;
v_sql := SUBSTR(v_sql, v_comment_end + 1);
ELSIF SUBSTR(v_sql, 1, 2) = '/*' THEN
v_comment_end := INSTR(v_sql, '*/');
IF v_comment_end = 0 THEN
RAISE_APPLICATION_ERROR(-20814, 'Unclosed generated SQL comment is not allowed.');
END IF;
v_sql := SUBSTR(v_sql, v_comment_end + 2);
ELSE
EXIT;
END IF;
END LOOP;
v_sql := RTRIM(TRIM(v_sql), ';');
RETURN TRIM(v_sql);
END normalized_sql;
PROCEDURE assert_safe_kb_select(p_sql IN VARCHAR2) IS
-- Select AI normally double-quotes Oracle identifiers. Validate a quote-free
-- copy so POC_2.KB_CLAIMS and "POC_2"."KB_CLAIMS" follow the same policy.
v_upper VARCHAR2(32767) := REPLACE(UPPER(p_sql), '"', '');
-- Mask string literals before checking semicolons, keywords, packages, and
-- data dictionary names. A harmless CASE output string should not be treated
-- as executable SQL syntax.
v_lexical VARCHAR2(32767) :=
REGEXP_REPLACE(REPLACE(UPPER(p_sql), '"', ''), '''(''''|[^''])*''', '''X''');
BEGIN
IF NOT REGEXP_LIKE(v_lexical, '^(SELECT|WITH)[[:space:]]') THEN
RAISE_APPLICATION_ERROR(
-20813,
'Only a single SELECT or WITH query is allowed. Generated prefix: '
|| SUBSTR(v_upper, 1, 160)
);
END IF;
IF INSTR(v_lexical, ';') > 0
OR INSTR(v_lexical, '--') > 0
OR INSTR(v_lexical, '/*') > 0
OR INSTR(v_lexical, '*/') > 0 THEN
RAISE_APPLICATION_ERROR(
-20814,
'Comments or multiple SQL statements remain after normalization. Generated prefix: '
|| SUBSTR(v_upper, 1, 160)
);
END IF;
IF REGEXP_LIKE(
v_lexical,
'(^|[^A-Z_])(ALTER|BEGIN|COMMIT|CREATE|DECLARE|DELETE|DROP|EXECUTE|GRANT|INSERT|MERGE|REVOKE|ROLLBACK|TRUNCATE|UPDATE)([^A-Z_]|$)'
) THEN
RAISE_APPLICATION_ERROR(-20815, 'Only read-only SQL is allowed.');
END IF;
IF INSTR(v_lexical, 'DBMS_') > 0
OR INSTR(v_lexical, 'UTL_') > 0
OR INSTR(v_lexical, 'SYS.') > 0
OR INSTR(v_lexical, 'ADMIN.') > 0
OR INSTR(v_lexical, 'CB_') > 0
OR INSTR(v_lexical, 'ALL_') > 0
OR INSTR(v_lexical, 'DBA_') > 0
OR INSTR(v_lexical, 'USER_') > 0
OR INSTR(v_lexical, 'FOR UPDATE') > 0 THEN
RAISE_APPLICATION_ERROR(-20816, 'System, backoffice, and locking objects are not allowed.');
END IF;
IF NOT REGEXP_LIKE(
v_lexical,
'(FROM|JOIN)[[:space:]]+(POC_2[.])?KB_(CUSTOMERS|PRODUCTS|CONTRACTS|COVERAGES|CLAIMS|EXTERNAL_HOLDINGS|STAKEHOLDERS)([[:space:],)]|$)'
) THEN
RAISE_APPLICATION_ERROR(-20817, 'The generated SQL must query a permitted KB business table.');
END IF;
END assert_safe_kb_select;
PROCEDURE open_query(
p_prompt IN CLOB,
p_limit IN PLS_INTEGER,
p_rows OUT SYS_REFCURSOR,
p_generated_sql OUT CLOB
) IS
v_prompt VARCHAR2(4000);
v_generation_prompt CLOB;
v_generated_sql CLOB;
v_sql VARCHAR2(32767);
v_limit PLS_INTEGER;
BEGIN
IF p_prompt IS NULL OR DBMS_LOB.GETLENGTH(TRIM(p_prompt)) = 0 THEN
RAISE_APPLICATION_ERROR(-20801, 'prompt is required.');
END IF;
IF DBMS_LOB.GETLENGTH(p_prompt) > c_max_prompt_length THEN
RAISE_APPLICATION_ERROR(-20802, 'prompt must be 4,000 characters or fewer.');
END IF;
v_prompt := TRIM(DBMS_LOB.SUBSTR(p_prompt, c_max_prompt_length, 1));
IF REGEXP_LIKE(UPPER(v_prompt), 'SELECT[[:space:]]+AI') THEN
RAISE_APPLICATION_ERROR(-20803, 'SELECT AI action prefixes are not allowed in prompt.');
END IF;
v_limit := LEAST(GREATEST(NVL(p_limit, 50), 1), c_max_rows);
v_generation_prompt :=
'Return exactly one Oracle SQL SELECT or WITH statement and no markdown. '
|| 'Use only these POC_2 business tables: KB_CUSTOMERS, KB_PRODUCTS, KB_CONTRACTS, '
|| 'KB_COVERAGES, KB_CLAIMS, KB_EXTERNAL_HOLDINGS, KB_STAKEHOLDERS. '
|| 'Never use DDL, DML, PL/SQL, system views, backoffice tables, comments, or FOR UPDATE. '
|| 'The caller request is: ' || v_prompt;
v_generated_sql := DBMS_CLOUD_AI.GENERATE(
prompt => v_generation_prompt,
profile_name => c_profile_name,
action => 'showsql'
);
v_sql := normalized_sql(v_generated_sql);
assert_safe_kb_select(v_sql);
-- CB_AGENT_CTX was set by CB_ORDS before this call. Parsing this statement
-- in the same database session makes Oracle evaluate POC_2 VPD policies.
OPEN p_rows FOR
'SELECT * FROM (' || v_sql || ') WHERE ROWNUM <= :row_limit'
USING v_limit;
p_generated_sql := v_sql;
END open_query;
END kb_select_ai_vpd_query_api;
/
SHOW ERRORS
PROMPT === Granting fixed query API to CB_ORDS ===
GRANT EXECUTE ON poc_2.kb_select_ai_vpd_query_api TO cb_ords;
PROMPT === POC_2 Select AI VPD query API ready ===
EXIT

View File

@@ -0,0 +1,149 @@
-- ============================================================
-- 66_kb_select_ai_vpd_query_ords.sql
--
-- POST /ords/cb-ords/kb-select-ai-vpd/query
-- Authorization: Bearer <VPD token>
-- {"prompt":"채널별 청구 건수를 보여줘", "limit":50}
--
-- Run as CB_ORDS after 65. The Bearer token is resolved before Select AI
-- generates and executes its single validated read-only SQL statement.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Granting context setup to the ORDS gateway ===
GRANT EXECUTE ON cb_ords_handler_pkg TO ords_public_user;
PROMPT === Resetting only the VPD Select AI query module ===
BEGIN
ORDS.DELETE_MODULE(p_module_name => 'kb.select.ai.vpd');
EXCEPTION
WHEN OTHERS THEN NULL;
END;
/
PROMPT === Creating VPD-aware Select AI query endpoint ===
BEGIN
ORDS.DEFINE_MODULE(
p_module_name => 'kb.select.ai.vpd',
p_base_path => 'kb-select-ai-vpd/',
p_items_per_page => 0,
p_status => 'PUBLISHED'
);
ORDS.DEFINE_TEMPLATE(
p_module_name => 'kb.select.ai.vpd',
p_pattern => 'query'
);
ORDS.DEFINE_HANDLER(
p_module_name => 'kb.select.ai.vpd',
p_pattern => 'query',
p_method => 'POST',
p_source_type => ORDS.source_type_plsql,
p_source => q'~
DECLARE
v_body_text CLOB;
v_prompt VARCHAR2(4000);
v_limit PLS_INTEGER;
v_rows SYS_REFCURSOR;
v_generated_sql CLOB;
v_response CLOB;
v_error_code NUMBER;
v_error_message VARCHAR2(4000);
BEGIN
-- Resolves the Bearer token and sets the VPD/ASO application context in
-- this very ORDS database session before the generated SQL is parsed.
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
v_body_text := :body_text;
v_prompt := JSON_VALUE(v_body_text, '$.prompt' RETURNING VARCHAR2(4000) NULL ON ERROR);
v_limit := JSON_VALUE(v_body_text, '$.limit' RETURNING NUMBER DEFAULT 50 ON ERROR);
IF v_prompt IS NULL OR TRIM(v_prompt) IS NULL THEN
RAISE_APPLICATION_ERROR(-20801, 'prompt is required');
END IF;
IF v_limit IS NULL OR v_limit < 1 OR v_limit > 100 THEN
RAISE_APPLICATION_ERROR(-20802, 'limit must be between 1 and 100');
END IF;
-- Dynamic PL/SQL keeps ORDS gateway validation limited to the local handler
-- package while CB_ORDS invokes only the fixed POC_2 API granted to it.
EXECUTE IMMEDIATE
'BEGIN poc_2.kb_select_ai_vpd_query_api.open_query(:prompt, :row_limit, :rows, :sql); END;'
USING IN v_prompt, IN v_limit, OUT v_rows, OUT v_generated_sql;
:status_code := 200;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('profile', 'KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1');
APEX_JSON.WRITE('generatedSql', v_generated_sql);
APEX_JSON.WRITE('items', v_rows);
APEX_JSON.CLOSE_OBJECT;
cb_ords_handler_pkg.clear_vpd_context;
EXCEPTION
WHEN OTHERS THEN
v_error_code := SQLCODE;
v_error_message := SQLERRM;
cb_ords_handler_pkg.clear_vpd_context;
:status_code := CASE
WHEN v_error_code IN (-20001, -20002) THEN 403
WHEN v_error_code BETWEEN -20899 AND -20800 THEN 400
WHEN v_error_code BETWEEN -20199 AND -20100 THEN 403
ELSE 500
END;
SELECT JSON_OBJECT(
'errorCode' VALUE v_error_code,
'error' VALUE CASE
WHEN v_error_code IN (-20001, -20002) THEN
'VPD token is missing, invalid, expired, or has no access permission.'
WHEN v_error_code BETWEEN -20899 AND -20800 THEN v_error_message
ELSE 'Select AI query execution failed.'
END
RETURNING CLOB
)
INTO v_response
FROM dual;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
HTP.P(v_response);
END;
~',
p_items_per_page => 0
);
ORDS.DEFINE_PARAMETER(
p_module_name => 'kb.select.ai.vpd',
p_pattern => 'query',
p_method => 'POST',
p_name => 'Authorization',
p_bind_variable_name => 'auth_header',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => 'kb.select.ai.vpd',
p_pattern => 'query',
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'
);
COMMIT;
END;
/
PROMPT === VPD-aware Select AI query endpoint ready ===
PROMPT Path: /ords/cb-ords/kb-select-ai-vpd/query
EXIT

View File

@@ -0,0 +1,125 @@
-- ============================================================
-- 67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql
--
-- 목적:
-- 과거 VPD Column-Level Security(CLS) PoC에서 생성된
-- POC_2.KB_CONTRACTS.PREMIUM 대상 VPD 컬럼 정책을 제거한다.
--
-- 배경:
-- 현재 운영 설계는 다음처럼 역할을 분리한다.
--
-- * VPD/DBMS_RLS : 행 수준 필터링만 담당
-- * ASO/Data Redaction : 컬럼 마스킹만 담당
--
-- 따라서 DBMS_RLS.ADD_POLICY의 sec_relevant_cols 기반 컬럼 제어 정책은
-- 운영 정책에서 제거한다. 이 스크립트는 named policy 하나만 삭제하며,
-- ASO/Data Redaction 정책은 건드리지 않는다.
--
-- 삭제 대상:
-- object_schema : POC_2
-- object_name : KB_CONTRACTS
-- policy_name : KB_PREMIUM_CLS_POLICY
-- column : PREMIUM
--
-- 실행 권한:
-- ADMIN 또는 DBMS_RLS.DROP_POLICY 수행 권한이 있는 계정.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Before: legacy VPD CLS policy ===
COLUMN object_owner FORMAT A18
COLUMN object_name FORMAT A32
COLUMN policy_name FORMAT A36
COLUMN function FORMAT A40
COLUMN enable FORMAT A10
COLUMN policy_type FORMAT A20
SELECT object_owner,
object_name,
policy_name,
pf_owner,
package,
function,
enable,
policy_type,
sel
FROM dba_policies
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY';
PROMPT === Before: security relevant columns ===
COLUMN sec_rel_column FORMAT A32
SELECT object_owner,
object_name,
policy_name,
sec_rel_column
FROM dba_sec_relevant_cols
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY';
PROMPT === Dropping only the legacy VPD CLS policy ===
DECLARE
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM dba_policies
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY';
IF v_count > 0 THEN
DBMS_RLS.DROP_POLICY(
object_schema => 'POC_2',
object_name => 'KB_CONTRACTS',
policy_name => 'KB_PREMIUM_CLS_POLICY'
);
END IF;
END;
/
PROMPT === After: legacy VPD CLS policy ===
SELECT object_owner,
object_name,
policy_name,
pf_owner,
package,
function,
enable,
policy_type,
sel
FROM dba_policies
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY';
PROMPT === After: security relevant columns ===
SELECT object_owner,
object_name,
policy_name,
sec_rel_column
FROM dba_sec_relevant_cols
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY';
PROMPT === ASO/Data Redaction on PREMIUM is intentionally not changed ===
COLUMN column_name FORMAT A24
COLUMN function_type FORMAT A28
SELECT object_owner,
object_name,
column_name,
function_type,
function_parameters
FROM redaction_columns
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND UPPER(column_name) = 'PREMIUM';
PROMPT === Legacy KB_CONTRACTS premium VPD CLS cleanup complete ===

View File

@@ -11,7 +11,41 @@ public record BackofficeProperties(
Ai ai Ai ai
) { ) {
public record Security(String adminUser, String adminPassword, boolean requireHttps) { public record Security(
String adminUser,
String adminPassword,
String adminPasswordHash,
boolean guestEnabled,
String guestUser,
String guestPassword,
String guestPasswordHash,
boolean requireHttps,
boolean rememberMeEnabled,
String rememberMeKey,
int rememberMeDays
) {
/** Remember-me is intentionally unavailable over HTTP or without a stable secret key. */
public boolean rememberMeConfigured() {
return requireHttps
&& rememberMeEnabled
&& rememberMeKey != null
&& !rememberMeKey.isBlank()
&& rememberMeDays >= 1
&& rememberMeDays <= 90;
}
public int rememberMeValiditySeconds() {
return rememberMeDays * 24 * 60 * 60;
}
public boolean guestConfigured() {
return guestEnabled
&& guestUser != null
&& !guestUser.isBlank()
&& ((guestPassword != null && !guestPassword.isBlank())
|| (guestPasswordHash != null && !guestPasswordHash.isBlank()));
}
} }
public record Token(int maxDays) { public record Token(int maxDays) {
@@ -22,15 +56,20 @@ public record BackofficeProperties(
public record Ai( public record Ai(
boolean enabled, boolean enabled,
String provider,
String baseUrl, String baseUrl,
String model, String model,
String apiKey, String apiKey,
Duration timeout, Duration timeout,
String embeddingModel String embeddingModel,
String ociConfigFile,
String ociProfile,
String ociRegion,
String ociCompartmentId
) { ) {
public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) { public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
this(enabled, baseUrl, model, apiKey, timeout, ""); this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
} }
} }
} }

View File

@@ -1,71 +0,0 @@
package com.cloudhandson.vpdbackoffice.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Authenticates only the streamable HTTP MCP endpoint with a service token.
* Business-data authorization remains the bearerToken tool argument, which is
* resolved to the VPD context by the ORDS handler.
*/
@Component
public class McpAccessTokenFilter extends OncePerRequestFilter {
private final String accessToken;
public McpAccessTokenFilter(@Value("${backoffice.mcp.access-token:}") String accessToken) {
this.accessToken = accessToken == null ? "" : accessToken.trim();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"/mcp".equals(request.getRequestURI());
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String bearerToken = bearerToken(request.getHeader("Authorization"));
if (!accessToken.isBlank() && bearerToken != null && constantTimeEquals(accessToken, bearerToken)) {
var authentication = new UsernamePasswordAuthenticationToken(
"mcp-client",
null,
List.of(new SimpleGrantedAuthority("ROLE_MCP"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String bearerToken(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return null;
}
String value = authorization.substring(7).trim();
return value.isEmpty() ? null : value;
}
private boolean constantTimeEquals(String expected, String actual) {
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
actual.getBytes(StandardCharsets.UTF_8)
);
}
}

View File

@@ -1,7 +1,6 @@
package com.cloudhandson.vpdbackoffice.config; package com.cloudhandson.vpdbackoffice.config;
import java.time.Duration; import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -20,7 +19,6 @@ public class OrdsClientConfig {
} }
@Bean @Bean
@Qualifier("ordsAgentRestTemplate")
RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) { RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) {
Duration timeout = properties.ords().agentTimeout(); Duration timeout = properties.ords().agentTimeout();
return new RestTemplateBuilder() return new RestTemplateBuilder()

View File

@@ -1,16 +1,18 @@
package com.cloudhandson.vpdbackoffice.config; package com.cloudhandson.vpdbackoffice.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.http.HttpMethod;
@Configuration @Configuration
public class SecurityConfig { public class SecurityConfig {
@@ -19,23 +21,48 @@ public class SecurityConfig {
SecurityFilterChain securityFilterChain( SecurityFilterChain securityFilterChain(
HttpSecurity http, HttpSecurity http,
BackofficeProperties properties, BackofficeProperties properties,
McpAccessTokenFilter mcpAccessTokenFilter UserDetailsService userDetailsService
) throws Exception { ) throws Exception {
if (properties.security().requireHttps()) { if (properties.security().requireHttps()) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
} }
var security = properties.security();
if (security.rememberMeConfigured()) {
http.rememberMe(rememberMe -> rememberMe
.key(security.rememberMeKey())
.userDetailsService(userDetailsService)
.rememberMeParameter("remember-me")
.rememberMeCookieName("VPD_REMEMBER_ME")
.tokenValiditySeconds(security.rememberMeValiditySeconds())
.useSecureCookie(true)
.alwaysRemember(false));
}
return http return http
.addFilterBefore(mcpAccessTokenFilter, BasicAuthenticationFilter.class)
.csrf(csrf -> csrf.ignoringRequestMatchers( .csrf(csrf -> csrf.ignoringRequestMatchers(
"/mcp", "/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages")) "/mcp", "/mcp/messages", "/mcp/*/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts .headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true) .includeSubDomains(true)
.maxAgeInSeconds(31_536_000))) .maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages") .requestMatchers(
"/css/**", "/js/**", "/webjars/**",
"/mcp", "/mcp/sse", "/mcp/*/sse", "/mcp/messages", "/mcp/*/messages")
.permitAll() .permitAll()
.requestMatchers(HttpMethod.POST, "/mcp").hasAnyRole("ADMIN", "MCP") .requestMatchers(HttpMethod.POST, "/login", "/logout").permitAll()
.requestMatchers(HttpMethod.POST,
"/probe",
"/vector-knowledge/search",
"/security-sql-scripts/explanation",
"/mcp-chatbot",
"/mcp-client-demo",
"/mcp-reasoning")
.authenticated()
.requestMatchers(HttpMethod.PUT, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.httpBasic(basic -> { .httpBasic(basic -> {
}) })
@@ -52,11 +79,26 @@ public class SecurityConfig {
PasswordEncoder passwordEncoder PasswordEncoder passwordEncoder
) { ) {
var security = properties.security(); var security = properties.security();
var user = User.withUsername(security.adminUser()) String encodedPassword = security.adminPasswordHash() == null || security.adminPasswordHash().isBlank()
.password(passwordEncoder.encode(security.adminPassword())) ? passwordEncoder.encode(security.adminPassword())
: security.adminPasswordHash();
List<UserDetails> users = new ArrayList<>();
users.add(User.withUsername(security.adminUser())
// BCrypt encoding includes a random salt. Re-encoding the configured
// password on every startup would invalidate remember-me signatures.
.password(encodedPassword)
.roles("ADMIN") .roles("ADMIN")
.build(); .build());
return new InMemoryUserDetailsManager(user); if (security.guestConfigured()) {
String encodedGuestPassword = security.guestPasswordHash() == null || security.guestPasswordHash().isBlank()
? passwordEncoder.encode(security.guestPassword())
: security.guestPasswordHash();
users.add(User.withUsername(security.guestUser())
.password(encodedGuestPassword)
.roles("VIEWER")
.build());
}
return new InMemoryUserDetailsManager(users);
} }
@Bean @Bean

View File

@@ -0,0 +1,31 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record ColumnMaskingRule(
long columnId,
long objectId,
String owner,
String objectName,
String columnName,
long ruleId,
String ruleCode,
String ruleName,
String templateCode,
String ruleEnabledYn
) {
public String targetLabel() {
return owner + "." + objectName + "." + columnName;
}
public MaskingTemplate template() {
return MaskingTemplate.from(templateCode);
}
public String ruleLabel() {
return ruleName + " · " + template().label();
}
public boolean ruleEnabled() {
return "Y".equalsIgnoreCase(ruleEnabledYn);
}
}

View File

@@ -0,0 +1,77 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
/**
* Read-only comparison between the configured masking metadata and the
* corresponding Oracle Data Redaction policy stored in the database.
*/
public record MaskingPolicyStatus(
String owner,
String objectName,
String policyName,
String enabled,
int configuredColumnCount,
int appliedColumnCount,
int mismatchedColumnCount,
int legacyVpdColumnPolicyCount
) {
public String targetLabel() {
return owner + "." + objectName;
}
public boolean policyEnabled() {
return "YES".equalsIgnoreCase(enabled);
}
/** A policy with no configured active column is correct only while disabled. */
public boolean inactiveAsExpected() {
return configuredColumnCount == 0 && !policyEnabled() && legacyVpdColumnPolicyCount == 0;
}
public boolean applied() {
return configuredColumnCount > 0
&& policyEnabled()
&& configuredColumnCount == appliedColumnCount
&& mismatchedColumnCount == 0
&& legacyVpdColumnPolicyCount == 0;
}
public String statusLabel() {
if (applied()) {
return "적용됨";
}
if (inactiveAsExpected()) {
return "미적용";
}
return "설정-DB 불일치";
}
public String badgeClass() {
if (applied()) {
return "text-bg-success";
}
if (inactiveAsExpected()) {
return "text-bg-secondary";
}
return "text-bg-danger";
}
public String detail() {
if (applied()) {
return "활성 규칙과 DB 정책 컬럼이 일치합니다.";
}
if (legacyVpdColumnPolicyCount > 0) {
return "기존 컬럼 NULL 정책(VPD CLS)이 활성입니다. ASO 설정과 별도로 값을 NULL 처리할 수 있습니다.";
}
if (inactiveAsExpected()) {
return "활성 컬럼 기본 규칙이 없어 DB 정책을 중지 상태로 보관합니다.";
}
if (configuredColumnCount > 0 && !policyEnabled()) {
return "활성 규칙이 있으나 DB 정책이 비활성입니다. 동기화가 필요합니다.";
}
if (configuredColumnCount == 0) {
return "활성 규칙이 없는데 DB 정책이 활성입니다. 동기화가 필요합니다.";
}
return "설정 컬럼과 DB Redaction 컬럼이 다릅니다. 동기화가 필요합니다.";
}
}

View File

@@ -0,0 +1,23 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record MaskingRule(
long ruleId,
String ruleCode,
String ruleName,
String templateCode,
String description,
String enabledYn
) {
public boolean enabled() {
return "Y".equalsIgnoreCase(enabledYn);
}
public MaskingTemplate template() {
return MaskingTemplate.from(templateCode);
}
public String templateLabel() {
return template().label();
}
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record MaskingRuleCreateCommand(
String ruleCode,
String ruleName,
String templateCode,
String description
) {
}

View File

@@ -0,0 +1,76 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
import java.util.Arrays;
/**
* Curated Data Redaction behaviours. A rule selects one template; operators
* never enter raw DBMS_REDACT expressions from the backoffice UI.
*/
public enum MaskingTemplate {
NULLIFY(
"NULLIFY",
"값 숨김 (NULL)",
"값을 NULL로 반환합니다. ASO/Data Redaction 컬럼 마스킹에 사용합니다.",
"DBMS_REDACT.NULLIFY",
"NULL"),
FULL(
"FULL",
"전체 마스킹",
"전체 값을 가립니다. Oracle 기본값은 문자형 공백, 숫자형 0입니다.",
"DBMS_REDACT.FULL",
"문자형은 공백 / 숫자형은 0"),
TEXT_PARTIAL(
"TEXT_PARTIAL",
"문자열 일부 마스킹",
"첫 글자만 남기고 나머지를 가리는 사전 정의 문자열 규칙입니다.",
"DBMS_REDACT.REGEXP",
"A******** (예시)"),
RRN_PARTIAL(
"RRN_PARTIAL",
"주민등록번호 부분 마스킹",
"앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
"DBMS_REDACT.REGEXP",
"900101-******* (예시)");
private final String code;
private final String label;
private final String description;
private final String asoFunction;
private final String previewResult;
MaskingTemplate(String code, String label, String description, String asoFunction, String previewResult) {
this.code = code;
this.label = label;
this.description = description;
this.asoFunction = asoFunction;
this.previewResult = previewResult;
}
public String code() {
return code;
}
public String label() {
return label;
}
public String description() {
return description;
}
public String asoFunction() {
return asoFunction;
}
/** Human-readable result shown before an administrator assigns the rule. */
public String previewResult() {
return previewResult;
}
public static MaskingTemplate from(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("지원하지 않는 마스킹 템플릿입니다: " + code));
}
}

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record UserMaskingRule(
long userId,
String username,
long columnId,
String owner,
String objectName,
String columnName,
String ruleName,
String templateCode,
String decision,
String activeYn
) {
public boolean unmasked() {
return "UNMASK".equalsIgnoreCase(decision);
}
public boolean active() {
return "Y".equalsIgnoreCase(activeYn);
}
public String decisionLabel() {
return unmasked() ? "원문 표시 허용" : "기본값과 동일 · 마스킹";
}
public String targetLabel() {
return owner + "." + objectName + "." + columnName;
}
public String ruleLabel() {
return ruleName + " · " + MaskingTemplate.from(templateCode).label();
}
}

View File

@@ -47,10 +47,10 @@ public record OperationStatusRow(
return "ORDS path와 handler schema/module/template 매핑을 확인하세요."; return "ORDS path와 handler schema/module/template 매핑을 확인하세요.";
} }
if (policyNames == null || policyNames.isBlank()) { if (policyNames == null || policyNames.isBlank()) {
return "VPD policy를 적용하세요."; return "행 접근 정책(VPD)을 적용하세요.";
} }
if (policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) { if (policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) {
return "VPD policy enable 상태를 확인하세요."; return "행 접근 정책(VPD) enable 상태를 확인하세요.";
} }
if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) { if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) {
return "Policy function 컴파일 오류를 확인하세요."; return "Policy function 컴파일 오류를 확인하세요.";

View File

@@ -13,4 +13,69 @@ public record PermissionView(
String filterPreview, String filterPreview,
String nullPolicyPreview String nullPolicyPreview
) { ) {
public String businessRuleSummary() {
if (rules == null || rules.isBlank()) {
return "-";
}
return java.util.Arrays.stream(rules.split(","))
.map(String::trim)
.filter(value -> !value.isBlank())
.map(PermissionView::businessRuleLabel)
.collect(java.util.stream.Collectors.joining(System.lineSeparator() + "AND "));
}
public String storedRuleSummary() {
return rules == null || rules.isBlank() ? "-" : rules;
}
public String vpdMappingSummary() {
return filterPreview == null || filterPreview.isBlank() ? "-" : filterPreview;
}
public String columnControlSummary() {
if (visibleColumns == null || visibleColumns.isBlank()) {
return "컬럼 원문/마스킹은 컬럼 마스킹에서 관리";
}
return "기존 권한별 표시 예외 값: " + visibleColumns
+ " · 신규 컬럼 제어는 컬럼 마스킹에서 관리";
}
private static String businessRuleLabel(String rawRule) {
String upper = rawRule.toUpperCase(java.util.Locale.ROOT);
if ("ALL".equals(upper)) {
return "전체 행";
}
if (upper.contains(" TOKEN_SUBJECT")) {
return "토큰으로 식별된 이해관계자 본인 행";
}
if (upper.contains(" OWN_CONTRACT")) {
return "담당 설계사 본인 계약";
}
if (upper.contains(" CHANNEL_CONTRACT")) {
return "토큰 사용자의 채널 계약";
}
if (upper.contains(" OWN_CUSTOMER")) {
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
}
if (upper.contains(" CHANNEL_CUSTOMER")) {
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
}
if (upper.contains(" STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
}
if (upper.startsWith("STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.substring("STATIC_SQL ".length());
}
if (upper.contains(" MY_DEPT")) {
return "내 부서 행";
}
if (upper.contains(" SELF")) {
return "내 사번/소유자 행";
}
if (upper.contains(" TAG ")) {
return "태그 조건: " + rawRule;
}
return rawRule;
}
} }

View File

@@ -238,7 +238,7 @@ public record ProbeResult(
case INVALID_TOKEN -> "입력한 토큰 정보가 일치하지 않습니다."; case INVALID_TOKEN -> "입력한 토큰 정보가 일치하지 않습니다.";
case OBJECT_DISABLED -> "검증 대상이 비활성 상태입니다."; case OBJECT_DISABLED -> "검증 대상이 비활성 상태입니다.";
case OBJECT_NOT_ACCESSIBLE -> "현재 권한으로 이 대상에 접근할 수 없습니다."; case OBJECT_NOT_ACCESSIBLE -> "현재 권한으로 이 대상에 접근할 수 없습니다.";
case VPD_FILTER_ERROR -> "이 객체에 연결된 별도 VPD Filter가 실행되지 않습니다."; case VPD_FILTER_ERROR -> "이 객체에 연결된 행 접근 Filter가 실행되지 않습니다.";
case ORDS_PATH_NOT_FOUND -> "검증 대상의 ORDS 경로를 찾지 못했습니다."; case ORDS_PATH_NOT_FOUND -> "검증 대상의 ORDS 경로를 찾지 못했습니다.";
case ORDS_NOT_CONFIGURED -> "ORDS 연결 주소가 아직 설정되지 않았습니다."; case ORDS_NOT_CONFIGURED -> "ORDS 연결 주소가 아직 설정되지 않았습니다.";
case ORDS_UNAVAILABLE -> "ORDS 서버에 연결할 수 없습니다."; case ORDS_UNAVAILABLE -> "ORDS 서버에 연결할 수 없습니다.";
@@ -250,9 +250,9 @@ public record ProbeResult(
public String plainSummary() { public String plainSummary() {
return switch (status) { return switch (status) {
case SUCCESS -> "토큰의 사용자와 역할을 기준으로 VPD가 적용되었고, 허용된 데이터 " + rowCount case SUCCESS -> "토큰의 사용자와 역할을 기준으로 행 접근 정책이 적용되었고, 허용된 데이터 " + rowCount
+ "개가 반환되었습니다."; + "개가 반환되었습니다.";
case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 VPD가 현재 사용자에게 허용한 행은 0개입니다. 권한 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다."; case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 행 접근 정책이 현재 사용자에게 허용한 행은 0개입니다. 행 접근 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다.";
case TOKEN_NOT_FOUND -> "입력한 원문과 일치하는 등록 기록이 현재 DB에 없습니다. 예전에 발급한 값이거나 다른 환경의 토큰일 수 있습니다."; case TOKEN_NOT_FOUND -> "입력한 원문과 일치하는 등록 기록이 현재 DB에 없습니다. 예전에 발급한 값이거나 다른 환경의 토큰일 수 있습니다.";
case TOKEN_INACTIVE -> "토큰은 DB에 있지만 만료되었거나 관리자가 회수해 더 이상 사용자 권한을 증명할 수 없습니다."; case TOKEN_INACTIVE -> "토큰은 DB에 있지만 만료되었거나 관리자가 회수해 더 이상 사용자 권한을 증명할 수 없습니다.";
case INVALID_TOKEN -> "화면에서 선택한 정보와 입력한 토큰 원문이 서로 다릅니다."; case INVALID_TOKEN -> "화면에서 선택한 정보와 입력한 토큰 원문이 서로 다릅니다.";
@@ -270,17 +270,17 @@ public record ProbeResult(
public String nextAction() { public String nextAction() {
return switch (status) { return switch (status) {
case SUCCESS -> "반환된 행과 마스킹 컬럼이 예상한 범위인지 확인하세요. 다르면 권한 화면의 행·열 규칙을 조정한 뒤 다시 검증하세요."; case SUCCESS -> "반환된 행과 ASO 마스킹 컬럼이 예상한 범위인지 확인하세요. 행 범위가 다르면 행 접근 규칙을, 컬럼 표시가 다르면 컬럼 마스킹을 조정한 뒤 다시 검증하세요.";
case VPD_DENY_EMPTY_RESULT -> "유효 권한 화면에서 사용자에게 직접 또는 그룹으로 상속된 역할과 행 규칙을 확인하세요."; case VPD_DENY_EMPTY_RESULT -> "유효 권한 화면에서 사용자에게 직접 또는 그룹으로 상속된 역할과 행 규칙을 확인하세요.";
case TOKEN_NOT_FOUND -> "토큰 화면에서 현재 환경의 사용자에게 새 토큰을 발급하고, 한 번만 표시되는 원문을 복사해 다시 검증하세요."; case TOKEN_NOT_FOUND -> "토큰 화면에서 현재 환경의 사용자에게 새 토큰을 발급하고, 한 번만 표시되는 원문을 복사해 다시 검증하세요.";
case TOKEN_INACTIVE -> "토큰 화면에서 활성 토큰을 새로 발급한 뒤 다시 검증하세요."; case TOKEN_INACTIVE -> "토큰 화면에서 활성 토큰을 새로 발급한 뒤 다시 검증하세요.";
case INVALID_TOKEN -> "복사한 원문이 맞는지 확인하고, 원문을 잃었다면 새 토큰을 발급하세요."; case INVALID_TOKEN -> "복사한 원문이 맞는지 확인하고, 원문을 잃었다면 새 토큰을 발급하세요.";
case OBJECT_DISABLED -> "보호 객체를 활성화하고 권한을 등록한 뒤 다시 검증하세요."; case OBJECT_DISABLED -> "보호 객체를 활성화하고 권한을 등록한 뒤 다시 검증하세요.";
case OBJECT_NOT_ACCESSIBLE -> "유효 권한과 ORDS handler의 대상 객체가 같은지 확인하세요."; case OBJECT_NOT_ACCESSIBLE -> "유효 권한과 ORDS handler의 대상 객체가 같은지 확인하세요.";
case VPD_FILTER_ERROR -> "토큰이나 권한을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 권한체계 자동 Filter로 복구하세요."; case VPD_FILTER_ERROR -> "토큰이나 행 접근 규칙을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 표준 행 접근 Filter로 복구하세요.";
case ORDS_PATH_NOT_FOUND -> "보호 객체의 ORDS 경로와 실제 module/template 경로를 맞춘 뒤 다시 실행하세요."; case ORDS_PATH_NOT_FOUND -> "보호 객체의 ORDS 경로와 실제 module/template 경로를 맞춘 뒤 다시 실행하세요.";
case ORDS_NOT_CONFIGURED -> "설정에서 ORDS 기준 주소를 등록한 뒤 백오피스를 재시작하세요."; case ORDS_NOT_CONFIGURED -> "설정에서 ORDS 기준 주소를 등록한 뒤 백오피스를 재시작하세요.";
case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "권한 설정을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요."; case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "행 접근 규칙을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요.";
case INVALID_ORDS_RESPONSE -> "ORDS handler가 rows 또는 items 배열을 반환하는지 확인하세요."; case INVALID_ORDS_RESPONSE -> "ORDS handler가 rows 또는 items 배열을 반환하는지 확인하세요.";
case UNKNOWN_ERROR -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요."; case UNKNOWN_ERROR -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요.";
}; };

View File

@@ -0,0 +1,4 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
public record SchemaAnnotation(String name, String value) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
import java.util.List;
public record SchemaMetadataColumn(
String columnName,
String dataType,
boolean nullable,
String comment,
List<SchemaAnnotation> annotations
) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import java.util.List;
public record SchemaMetadataView(
StructuredDataTable table,
String tableComment,
List<SchemaAnnotation> tableAnnotations,
List<SchemaMetadataColumn> columns
) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.securityscript;
/** Curated, version-controlled database script displayed read-only in the backoffice. */
public record SecuritySqlScript(
String scriptId,
String category,
String fileName,
String title,
String description,
String source
) {
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.securityscript;
/** Explanation generated from one immutable, curated security SQL script. */
public record SecuritySqlScriptExplanation(
String status,
String modelName,
String answer,
String prompt,
SecuritySqlScript script
) {
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.securityscript;
/** Metadata for selecting a curated security SQL script without loading its source. */
public record SecuritySqlScriptSummary(
String scriptId,
String category,
String fileName,
String title,
String description
) {
}

View File

@@ -0,0 +1,5 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
/** A saved VPD policy or filter description, addressed by a stable composite key. */
public record VpdDescriptionNote(String noteKey, String description) {
}

View File

@@ -0,0 +1,52 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface MaskingRuleMapper {
List<MaskingRule> findAllRules();
List<MaskingRule> findEnabledRules();
MaskingRule findRuleById(@Param("ruleId") long ruleId);
MaskingRule findRuleByCode(@Param("ruleCode") String ruleCode);
long nextRuleId();
void insertRule(@Param("ruleId") long ruleId, @Param("command") MaskingRuleCreateCommand command);
int updateRuleActive(@Param("ruleId") long ruleId, @Param("enabledYn") String enabledYn);
List<ColumnMaskingRule> findColumnRules();
List<MaskingPolicyStatus> findPolicyStatuses();
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
void upsertColumnRule(@Param("columnId") long columnId, @Param("ruleId") long ruleId);
int deleteColumnRule(@Param("columnId") long columnId);
int deleteUserRulesForColumn(@Param("columnId") long columnId);
List<UserMaskingRule> findUserRules();
UserMaskingRule findUserRule(@Param("userId") long userId, @Param("columnId") long columnId);
void upsertUserRule(
@Param("userId") long userId,
@Param("columnId") long columnId,
@Param("decision") String decision
);
int deleteUserRule(@Param("userId") long userId, @Param("columnId") long columnId);
}

Some files were not shown because too many files have changed in this diff Show More