Compare commits
120 Commits
be14b4c797
...
hmm-backof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce0341102 | ||
|
|
2d0b5a3c1e | ||
|
|
cbd49b6b05 | ||
|
|
84029ed633 | ||
|
|
6394bbf078 | ||
|
|
2f5fc2bfbd | ||
|
|
66fcb5d149 | ||
|
|
b13b913939 | ||
|
|
c0c5a36ba0 | ||
|
|
cc2c3e3e25 | ||
|
|
c519435266 | ||
|
|
ad1e4573a9 | ||
|
|
2bd373bcb3 | ||
|
|
7d53b5c75c | ||
|
|
fbf4c19c72 | ||
|
|
1c8ad8647f | ||
|
|
90c771af57 | ||
|
|
de0956ef03 | ||
|
|
2c790f76b7 | ||
|
|
91c96d7919 | ||
|
|
89a6f4e4bc | ||
|
|
e6432fa943 | ||
|
|
cbc645466b | ||
|
|
35782b8a50 | ||
|
|
7dd47d39b9 | ||
|
|
d868d7bbb8 | ||
|
|
9f46205057 | ||
|
|
e9d50e6a32 | ||
|
|
2e44ed0b97 | ||
|
|
af6add5a44 | ||
|
|
9bed75f0c0 | ||
|
|
cdb2c37ae3 | ||
|
|
65a2b27fcc | ||
|
|
0d9028ef13 | ||
|
|
1917df09a2 | ||
|
|
ce4b418603 | ||
|
|
da80041bcb | ||
|
|
a495e7a962 | ||
|
|
9e3840a4d7 | ||
|
|
eb5105fe72 | ||
|
|
62c5fb518c | ||
|
|
d6a558e45a | ||
|
|
ee355a9282 | ||
|
|
536938895a | ||
|
|
adfd50d1fd | ||
|
|
572dcd9a67 | ||
|
|
64965c294b | ||
|
|
0b55e5b16b | ||
|
|
ea5a116203 | ||
|
|
56b9b511c1 | ||
|
|
b5d3913de3 | ||
|
|
79ed5a158e | ||
|
|
323f746d8a | ||
|
|
a2112e07dc | ||
|
|
97ed5d38ab | ||
|
|
c4e158f639 | ||
|
|
8cd4fc6ade | ||
|
|
6bc39c68b8 | ||
|
|
aa99a7d672 | ||
|
|
7befdd0148 | ||
|
|
72b2e084ac | ||
|
|
8b86d58d95 | ||
|
|
8e44a59d0a | ||
|
|
f84ec7c7a0 | ||
|
|
e45956c7c6 | ||
|
|
d8c4482dc4 | ||
|
|
add62a5b40 | ||
|
|
12d6d8f013 | ||
|
|
50b23aa4ed | ||
|
|
fe78b89452 | ||
|
|
ba0530cafb | ||
|
|
8eb8c566c6 | ||
|
|
f860f0279c | ||
|
|
a90da6c7ff | ||
|
|
c2e6ff2cc9 | ||
|
|
7968ae73a0 | ||
|
|
1f6d97440f | ||
|
|
a3ba76f2f4 | ||
|
|
33399f93f5 | ||
|
|
5bdd24299e | ||
|
|
b48d9a0792 | ||
|
|
a40102a79a | ||
|
|
e18b30feab | ||
|
|
403298d474 | ||
|
|
fbeb18ccec | ||
|
|
61f7e4c775 | ||
|
|
1cb8052045 | ||
|
|
f3eddd692c | ||
|
|
ac57932472 | ||
|
|
9d3e384203 | ||
|
|
6921682bcd | ||
|
|
2ef04e6ab2 | ||
|
|
ef944a6758 | ||
|
|
2cdc738eb9 | ||
|
|
66b57da356 | ||
|
|
6b60757721 | ||
|
|
8e1e521a70 | ||
|
|
43f6e6e1af | ||
|
|
b77966abaf | ||
|
|
ef1331be4b | ||
|
|
fd09622c82 | ||
|
|
b7100cc62a | ||
|
|
ebcce66bf7 | ||
|
|
57d77d2df2 | ||
|
|
b39968d43f | ||
|
|
d301e4dad1 | ||
|
|
1c66b973a7 | ||
|
|
064a5bc31d | ||
|
|
a00db691f9 | ||
|
|
07ce6c038a | ||
|
|
bf393bffa5 | ||
|
|
07c090f6b0 | ||
|
|
f15b453aad | ||
|
|
699909212f | ||
|
|
2a038662e0 | ||
|
|
8830f07be1 | ||
|
|
7067941ddd | ||
|
|
670918f56f | ||
|
|
c52890e721 | ||
|
|
1218ae1719 |
77
.env.example
77
.env.example
@@ -26,12 +26,25 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}"
|
||||
# --- (2b) Spring Boot 백오피스 ---
|
||||
export BACKOFFICE_ADMIN_USER="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 안전값으로 덮어씁니다.
|
||||
export BACKOFFICE_BIND_ADDRESS="0.0.0.0"
|
||||
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none"
|
||||
export BACKOFFICE_REQUIRE_HTTPS="false"
|
||||
export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||
# HTTPS에서만 사용. key는 배포 환경의 secret으로 32자 이상 임의값을 넣고 Git에 저장하지 않습니다.
|
||||
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
||||
export BACKOFFICE_REMEMBER_ME_KEY=""
|
||||
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
||||
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다.
|
||||
export BACKOFFICE_ORDS_BASE_URL=""
|
||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
||||
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다.
|
||||
@@ -39,18 +52,60 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
||||
|
||||
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||
# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다.
|
||||
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}"
|
||||
export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}"
|
||||
export BACKOFFICE_SELECT_AI_PROFILE=""
|
||||
# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다.
|
||||
# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요.
|
||||
export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS"
|
||||
export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=""
|
||||
# Optional deployment-specific JSON contract. Keep project rules out of Java.
|
||||
export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE=""
|
||||
|
||||
# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
|
||||
# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
||||
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
|
||||
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]'
|
||||
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
|
||||
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
||||
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
||||
|
||||
# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
|
||||
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
|
||||
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
|
||||
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
||||
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
|
||||
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
|
||||
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
|
||||
export BACKOFFICE_MCP_TOOLS=''
|
||||
|
||||
# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다.
|
||||
export BACKOFFICE_MASKING_POLICIES=''
|
||||
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
|
||||
|
||||
# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||
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_MODEL="" # 예: openai.gpt-5.4-mini
|
||||
export BACKOFFICE_AI_API_KEY=""
|
||||
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에는 원문 키를 넣지 않습니다.
|
||||
export VPDTEST1_API_KEY=""
|
||||
export VPDTEST2_API_KEY=""
|
||||
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) ---
|
||||
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
||||
# 4명의 데모 유저:
|
||||
# vpduser_my → MySQL view 만 SELECT 가능
|
||||
@@ -63,7 +118,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26"
|
||||
export VPDUSER_NONE_PASSWORD="RowFilter#None26"
|
||||
|
||||
# --- (3b) OPTIONAL — DDS 변형 데모용 엔드유저 비밀번호 ---
|
||||
# sql/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용.
|
||||
# database/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용.
|
||||
# 동일한 4-user 매트릭스를 declarative Data Grants 로 재현. VPD 데모와 공존.
|
||||
export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
|
||||
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
|
||||
@@ -71,8 +126,20 @@ export DDSUSER_BOTH_PASSWORD="DdsGrant#Both26"
|
||||
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
|
||||
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
|
||||
|
||||
# --- (3c) DDS MCP SSE · OCI IAM Confidential Application ---
|
||||
# 실제 값은 .env 또는 배포 환경의 secret store에만 둔다.
|
||||
export DDS_OCI_IAM_CLIENT_ID=""
|
||||
export DDS_OCI_IAM_CLIENT_SECRET=""
|
||||
export DDS_OCI_IAM_DOMAIN_URL="" # 예: https://idcs-<unique>.identity.oraclecloud.com
|
||||
export DDS_OCI_IAM_DATABASE_SCOPE="" # OCI IAM의 DB resource scope
|
||||
export DDS_OCI_IAM_TOKEN_URI="" # 비우면 ${DDS_OCI_IAM_DOMAIN_URL}/oauth2/v1/token
|
||||
export DDS_OCI_IAM_TIMEOUT_SECONDS="10s"
|
||||
export DDS_OCI_IAM_REFRESH_SKEW_SECONDS="60s"
|
||||
export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
|
||||
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
||||
|
||||
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||
# database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
||||
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
|
||||
export PG_PORT="5432"
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# 환경/비밀 — 절대 commit 금지
|
||||
.env
|
||||
.runtime/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -17,6 +18,15 @@ logs/
|
||||
# Java / Maven
|
||||
target/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
data/
|
||||
|
||||
# Locally downloaded development tools (for example SQLcl)
|
||||
.tools/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 문서 작성 공통 규칙
|
||||
|
||||
이 저장소에서 새 문서를 만들거나 기존 문서를 크게 고칠 때는 아래 구조를 기본으로 한다.
|
||||
|
||||
1. 첫 문서(`README.md`)는 독자가 2~3분 안에 목적, 범위, 결정사항, 전체 구성, 현재 상태를 파악하는 **개요 문서**로 작성한다.
|
||||
2. README에는 복잡한 절차와 모든 오류 사례를 누적하지 않는다. 아래와 같이 역할별 상세 문서로 분리하고, 개요에서 명확한 링크를 제공한다.
|
||||
- `architecture.md`: 신뢰 경계, 컴포넌트 책임, 데이터·인증 흐름, 설계 결정
|
||||
- `cookbook.md`: 준비물, 단계별 적용 명령/화면값, 검증, 롤백
|
||||
- `troubleshooting.md`: 증상 → 원인 → 확인 방법 → 해결 → 재발 방지
|
||||
- 필요하면 `operations.md`, `security.md`, `adr/` 등 목적이 드러나는 파일을 추가한다.
|
||||
3. 그림은 한 장에 모든 세부사항을 넣지 않는다.
|
||||
- README에는 시스템 경계와 핵심 흐름만 보이는 **개요도**를 둔다.
|
||||
- 상세 연결·claim·포트·예외 흐름은 architecture 또는 cookbook의 **상세도**로 분리한다.
|
||||
- 각 그림 아래에는 독자가 알아야 할 결론을 한두 문장으로 적는다.
|
||||
4. Cookbook은 실제 적용 순서를 따르며, 각 단계에 입력값의 출처, 성공 판정, 실패 시 연결할 troubleshooting 항목을 포함한다.
|
||||
5. 비밀번호, client secret, access token, 개인 식별 정보는 어떤 문서·그림·명령 출력에도 기록하지 않는다. 위치와 안전한 조회/rotation 방법만 기록한다.
|
||||
6. README의 문서 지도와 각 상세 문서의 상호 링크를 변경 후 반드시 확인한다.
|
||||
|
||||
문서의 독자는 운영자·개발자·검토자다. 제품명과 전문 용어는 필요할 때만 쓰고, 처음 한 번은 평이한 말로 역할을 정의한다.
|
||||
|
||||
## HMM Select AI·HTML 리포트 보호 기준
|
||||
|
||||
1. 사용자 권한이 적용되는 HMM MCP의 기준 endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp`다.
|
||||
2. MCP route `search_carrier_performance`는 기존 DB Agent Tool `HMM_CARRIER_FEDERATION_SEARCH`를 호출한다. 이 Tool은 자연어를 Select AI Federation으로 처리하며, 고정 SQL·고정 데이터·대체 package로 바꾸지 않는다.
|
||||
3. `hmm-mcp.cloud-handson.com`은 별도 호환성·무 VPD 시험 환경이다. 기준 endpoint나 운영 Select AI 경로의 대체재로 사용하지 않는다.
|
||||
4. HTML 기능 추가 범위는 후속 표현 Tool `HMM_CARRIER_REPORT_RENDERER`와 포털의 범용 이전 결과 전달뿐이다. 렌더러는 사용자·권한·업무 데이터를 다시 조회하거나 보충하지 않는다.
|
||||
5. 조회 Tool, target, package 또는 endpoint를 생성·삭제·교체하기 전에는 현재 MCP discovery, `BACKOFFICE_MCP_TOOLS`, DB Agent Tool metadata를 먼저 대조한다. 기존 조회 경로 변경은 사용자가 명시적으로 요청한 경우에만 한다.
|
||||
6. 회귀 검증은 같은 Bearer 문맥에서 `search_carrier_performance`의 원본 행 수와 renderer에 전달된 `rows` 수가 같은지 확인한다. 대표 시나리오의 현재 기준은 E1001 팀장 질문에 대한 8건이지만, 코드는 E1001이나 8을 조건으로 사용하지 않는다.
|
||||
61
README.md
61
README.md
@@ -32,7 +32,7 @@ End-to-End 데모입니다.
|
||||
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
|
||||
`email` 컬럼은 항상 `NULL` 로 반환.
|
||||
|
||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`).
|
||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`).
|
||||
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
||||
|
||||
### 3. 두 가지 구현 — VPD 와 DDS
|
||||
@@ -57,11 +57,47 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
||||
|
||||
---
|
||||
|
||||
## 소스 위치와 Git 저장 기준
|
||||
|
||||
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은
|
||||
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다.
|
||||
|
||||
| 구분 | 저장 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 |
|
||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||
| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
|
||||
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
||||
| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 |
|
||||
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 |
|
||||
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 |
|
||||
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
|
||||
|
||||
Git 기준은 다음과 같다.
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
||||
| 운영 기준 remote | `gitea` |
|
||||
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
||||
| HMM 기준 branch | `hmm-backoffice` |
|
||||
|
||||
주의:
|
||||
|
||||
- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다.
|
||||
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다.
|
||||
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 구성요소
|
||||
|
||||
| 계층 | 객체 | 역할 |
|
||||
|---|---|---|
|
||||
| 원격 | `public.customers` (PG), `ecommerce_poc.customers` (MySQL) | 원본 데이터 |
|
||||
| 원격 HMM 데모 | `hmm_demo.carriers`, `hmm_demo.carrier_monthly_performance` (PG) | 가상 선사 기준정보와 최근 18개월 월간 KPI |
|
||||
| ADB - 연결 | `RDS_POSTGRES_LINK`, `RDS_LINK` | `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 로 만든 heterogeneous DB Link |
|
||||
| ADB - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
|
||||
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
||||
@@ -74,7 +110,7 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
||||
## 빠른 시작 (One-Click)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<you>/vpd-permission-poc.git
|
||||
git clone --branch hmm-backoffice https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git
|
||||
cd vpd-permission-poc
|
||||
|
||||
# 1) 환경값 채우기
|
||||
@@ -132,7 +168,7 @@ $EDITOR .env
|
||||
|
||||
## 데모 시나리오 — 2×2 source access matrix
|
||||
|
||||
`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||
`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||
|
||||
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
||||
|---|---|---|---|---|
|
||||
@@ -150,7 +186,7 @@ $EDITOR .env
|
||||
(`1=0` predicate) — **deny by default**.
|
||||
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음.
|
||||
|
||||
`sql/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트
|
||||
`database/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트
|
||||
스푸핑, DBMS_RLS 변경, 매핑 테이블 SELECT) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을
|
||||
보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다.
|
||||
|
||||
@@ -162,7 +198,7 @@ $EDITOR .env
|
||||
|
||||
## DDS 설정 핵심
|
||||
|
||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
|
||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
|
||||
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
||||
|
||||
### 사전 조건
|
||||
@@ -222,10 +258,23 @@ CREATE DATA GRANT admin.alice_apac_grant
|
||||
.
|
||||
├── run.sh # 원클릭 엔트리포인트
|
||||
├── .env.example
|
||||
├── vpd-backoffice/ # Spring Boot VPD 관리 백오피스
|
||||
│ ├── pom.xml
|
||||
│ └── src/
|
||||
├── ai-web-agent-console/ # Streamlit MCP 업무 에이전트
|
||||
│ ├── app.py
|
||||
│ ├── ai_web_agent_console/
|
||||
│ ├── config/
|
||||
│ └── tests/
|
||||
├── dds-backoffice/ # DDS 전용 Spring Boot 백오피스
|
||||
├── deploy/ # 애플리케이션별 배포 설정
|
||||
├── tests/ # 저장소 폴더 경계와 경로 규칙 검증
|
||||
├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼
|
||||
├── sql/
|
||||
├── database/
|
||||
│ ├── source/
|
||||
│ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows
|
||||
│ │ ├── postgres_hmm_carrier_performance.sql # 원격 PG: HMM 가상 선사 8개 + 월간 KPI 144 rows
|
||||
│ │ ├── postgres_hmm_carrier_performance_verify.sql # HMM 선사 실적 건수·패턴·제약·TLS·권한 검증
|
||||
│ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows
|
||||
│ └── adb/
|
||||
│ ├── 00_cleanup.sql # 멱등 teardown
|
||||
|
||||
53
ai-web-agent-console/.env.example
Normal file
53
ai-web-agent-console/.env.example
Normal file
@@ -0,0 +1,53 @@
|
||||
# AI Web Agent Console local/VM configuration example.
|
||||
# Copy to .env and keep real credentials out of Git.
|
||||
|
||||
# MCP
|
||||
HMM_MCP_BEARER_TOKEN=
|
||||
HMM_MCP_BEARER_TOKEN_E1001=
|
||||
HMM_MCP_BEARER_TOKEN_E1002=
|
||||
HMM_MCP_BEARER_TOKEN_E1003=
|
||||
HMM_MCP_BEARER_TOKEN_E1005=
|
||||
HMM_MCP_BEARER_TOKEN_E1007=
|
||||
AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45
|
||||
|
||||
# OCI Generative AI SDK
|
||||
OCI_AUTH_TYPE=config_file
|
||||
OCI_CONFIG_FILE=~/.oci/config
|
||||
OCI_PROFILE=DEFAULT
|
||||
OCI_GENAI_COMPARTMENT_ID=
|
||||
AI_WEB_AGENT_CONSOLE_MODEL_PROFILE=gpt54_mini_oci
|
||||
AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE=gpt54_mini_oci
|
||||
|
||||
# Optional model route overrides
|
||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini
|
||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION=us-chicago-1
|
||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
||||
|
||||
# Login gateway. Generate PBKDF2 hash and random cookie secret outside Git.
|
||||
AI_WEB_AGENT_CONSOLE_LOGIN_USER=demo-admin
|
||||
AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2=
|
||||
AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET=
|
||||
PORTAL_AUTH_BIND_ADDRESS=127.0.0.1
|
||||
PORTAL_AUTH_PORT=8621
|
||||
PORTAL_AUTH_SESSION_SECONDS=43200
|
||||
PORTAL_AUTH_REMEMBER_SECONDS=604800
|
||||
|
||||
# Product display
|
||||
AGENT_CONSOLE_NAME=HMM AI 업무 에이전트
|
||||
AGENT_CONSOLE_SHORT_NAME=HMM
|
||||
AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트
|
||||
AGENT_CONSOLE_PRIMARY_COLOR=#004b87
|
||||
|
||||
# Local conversation store
|
||||
AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH=data/ai_web_agent_console_chat.sqlite3
|
||||
|
||||
# Optional audit DB. Password and Wallet remain outside Git.
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE=.env
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER=ADMIN
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD=
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DSN=
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR=
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD=
|
||||
|
||||
# Optional query contract override
|
||||
AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH=config/hmm_hr_query_contracts.json
|
||||
59
ai-web-agent-console/README.md
Normal file
59
ai-web-agent-console/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# AI Web Agent Console
|
||||
|
||||
HMM 업무 데이터를 MCP 도구로 조회하고 OCI Generative AI로 답변을 구성하는 Streamlit
|
||||
애플리케이션이다. 과거 PoC 번호나 소스 추출 날짜는 애플리케이션 이름과 패키지 경계로
|
||||
사용하지 않는다.
|
||||
|
||||
## 구조
|
||||
|
||||
```text
|
||||
ai-web-agent-console/
|
||||
├── app.py Streamlit 실행 진입점
|
||||
├── ai_web_agent_console/ 인증, 화면, MCP, 모델, 질의 계약 모듈
|
||||
├── config/ 제품·모델·MCP·시나리오 JSON
|
||||
├── tests/ Python 회귀 테스트
|
||||
├── .env.example 환경변수 예시
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
cd ai-web-agent-console
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/streamlit run app.py --server.address 0.0.0.0 --server.port 8622
|
||||
```
|
||||
|
||||
인증 게이트웨이는 같은 디렉토리에서 다음과 같이 실행한다.
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m ai_web_agent_console.auth_gateway
|
||||
```
|
||||
|
||||
## 설정
|
||||
|
||||
- `config/app_profile.json`: 제품명, 설명, 아이콘, 색상
|
||||
- `config/mcp_servers.json`: MCP endpoint와 허용 도구
|
||||
- `config/model_profiles.json`: OCI Generative AI 모델 profile
|
||||
- `config/vpd_token_presets.json`: 데모 사용자와 토큰 환경변수 매핑
|
||||
- `config/hmm_demo_scenarios.json`: 화면의 예제 질문
|
||||
- `config/hmm_hr_query_contracts.json`: 질의별 필수 근거와 계산·시간 규칙
|
||||
|
||||
실제 토큰, DB 비밀번호, Wallet, OCI private key는 Git에 넣지 않는다. 데모 사용자 JSON은
|
||||
`HMM_MCP_BEARER_TOKEN_E1001`처럼 사용자별 환경변수 이름만 참조한다. token 원문은 설정 JSON이나
|
||||
문서에 기록하지 않는다. 사용자 preset들이 같은 환경변수를 공유하면 화면의 사용자만 바뀌고 DB
|
||||
권한 문맥은 바뀌지 않으므로 허용하지 않는다.
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
python3 -m compileall -q app.py ai_web_agent_console tests
|
||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
```
|
||||
|
||||
## 운영 호환성
|
||||
|
||||
운영 서버의 물리 경로 `/opt/hmm-poc4`와 기존 SQLite 테이블명은 대화 이력과 서비스 전환
|
||||
호환을 위해 유지할 수 있다. Git 폴더, Python 패키지와 실행 명령은 이 문서의 새 이름만
|
||||
사용한다.
|
||||
1
ai-web-agent-console/ai_web_agent_console/__init__.py
Normal file
1
ai-web-agent-console/ai_web_agent_console/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""
|
||||
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Reusable audit-tab renderer with data loaders supplied by the application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
AuditInventoryLoader = Callable[[], list[dict[str, Any]]]
|
||||
AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]]
|
||||
|
||||
|
||||
def render_hmm_audit_tab(
|
||||
st: Any,
|
||||
inventory_loader: AuditInventoryLoader,
|
||||
events_loader: AuditEventsLoader,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
"""Render HMM access audit data without owning DB connection details."""
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||
'감사로그 ( <strong>HMM 접근 관리</strong> )'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.markdown(
|
||||
'<div class="kb-audit-lead">'
|
||||
'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. '
|
||||
'이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
try:
|
||||
inventory = inventory_loader()
|
||||
except error_type as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
|
||||
event_types = tuple(
|
||||
str(item.get("event_type") or "").strip()
|
||||
for item in inventory
|
||||
if str(item.get("event_type") or "").strip()
|
||||
)
|
||||
st.markdown('<div class="kb-audit-heading">조회 조건</div>', unsafe_allow_html=True)
|
||||
with st.container(key="poc4_hmm_audit_filters"):
|
||||
event_column, status_column = st.columns(2)
|
||||
with event_column:
|
||||
selected_event_type = st.selectbox(
|
||||
"이벤트 유형",
|
||||
options=("", *event_types),
|
||||
format_func=lambda value: "전체 이벤트" if not value else value,
|
||||
key="poc4_hmm_audit_event_filter",
|
||||
)
|
||||
with status_column:
|
||||
selected_status = st.selectbox(
|
||||
"처리 상태",
|
||||
options=("", "SUCCESS", "FAILURE", "DENIED"),
|
||||
format_func=lambda value: "전체 상태" if not value else value,
|
||||
key="poc4_hmm_audit_status_filter",
|
||||
)
|
||||
days_column, limit_column, refresh_column = st.columns([1.5, 1, 0.8])
|
||||
with days_column:
|
||||
days = st.slider(
|
||||
"조회 기간",
|
||||
min_value=1,
|
||||
max_value=90,
|
||||
value=7,
|
||||
format="%d일",
|
||||
key="poc4_hmm_audit_days",
|
||||
)
|
||||
with limit_column:
|
||||
row_limit = st.number_input(
|
||||
"최대 건수",
|
||||
min_value=10,
|
||||
max_value=500,
|
||||
value=100,
|
||||
step=10,
|
||||
key="poc4_hmm_audit_row_limit",
|
||||
)
|
||||
with refresh_column:
|
||||
st.markdown('<div style="height: 28px"></div>', unsafe_allow_html=True)
|
||||
if st.button(
|
||||
"새로고침",
|
||||
icon=":material/refresh:",
|
||||
width="stretch",
|
||||
key="poc4_hmm_audit_refresh",
|
||||
):
|
||||
inventory_loader.clear()
|
||||
events_loader.clear()
|
||||
st.rerun()
|
||||
|
||||
try:
|
||||
events = events_loader(
|
||||
int(days), int(row_limit), selected_event_type, selected_status
|
||||
)
|
||||
except error_type as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
|
||||
success_count = sum(
|
||||
1 for item in events if str(item.get("status") or "").upper() == "SUCCESS"
|
||||
)
|
||||
with st.container(key="poc4_hmm_audit_metrics"):
|
||||
type_metric, event_metric, success_metric, failure_metric = st.columns(4)
|
||||
type_metric.metric("이벤트 유형", len(inventory))
|
||||
event_metric.metric("조회 이벤트", len(events))
|
||||
success_metric.metric("성공", success_count)
|
||||
failure_metric.metric("실패·거부", len(events) - success_count)
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-audit-heading">이벤트 유형 현황</div>'
|
||||
f'<div class="kb-audit-caption">현재 기록된 이벤트 유형 {len(inventory)}개 · '
|
||||
'유형별 누적 건수와 최근 발생 시각을 확인합니다.</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
with st.container(key="poc4_hmm_audit_inventory_panel"):
|
||||
with st.expander("감사 이벤트 유형", expanded=True):
|
||||
if inventory:
|
||||
st.dataframe(
|
||||
[
|
||||
{
|
||||
"이벤트 유형": str(item.get("event_type") or ""),
|
||||
"누적 건수": int(item.get("event_count") or 0),
|
||||
"최근 발생(KST)": str(item.get("latest_event_time") or ""),
|
||||
}
|
||||
for item in inventory
|
||||
],
|
||||
column_config={
|
||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
||||
"누적 건수": st.column_config.NumberColumn(width="small"),
|
||||
"최근 발생(KST)": st.column_config.TextColumn(width="medium"),
|
||||
},
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(360, 72 + 36 * len(inventory)),
|
||||
)
|
||||
else:
|
||||
st.caption("아직 기록된 HMM 접근 관리 이벤트가 없습니다.")
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-audit-heading">감사 이벤트</div>'
|
||||
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
|
||||
'처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
show_details = st.toggle(
|
||||
"상세 메시지 표시",
|
||||
value=True,
|
||||
key="poc4_hmm_audit_show_details",
|
||||
)
|
||||
if not events:
|
||||
st.info("선택한 조건에 해당하는 HMM 접근 관리 이벤트가 없습니다.")
|
||||
return
|
||||
|
||||
display_rows: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
row: dict[str, Any] = {
|
||||
"감사 ID": int(event.get("audit_id") or 0),
|
||||
"발생시각(KST)": str(event.get("event_time") or ""),
|
||||
"이벤트 유형": str(event.get("event_type") or ""),
|
||||
"상태": str(event.get("status") or ""),
|
||||
"토큰 Key ID": event.get("key_id"),
|
||||
"대상 Object ID": event.get("object_id"),
|
||||
"처리 행": event.get("row_count"),
|
||||
"오류 코드": str(event.get("error_code") or ""),
|
||||
}
|
||||
if show_details:
|
||||
row["메시지"] = str(event.get("message") or "")
|
||||
display_rows.append(row)
|
||||
|
||||
column_config: dict[str, Any] = {
|
||||
"감사 ID": st.column_config.NumberColumn(width="small"),
|
||||
"발생시각(KST)": st.column_config.TextColumn(width="medium"),
|
||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
||||
"상태": st.column_config.TextColumn(width="small"),
|
||||
"토큰 Key ID": st.column_config.NumberColumn(width="small"),
|
||||
"대상 Object ID": st.column_config.NumberColumn(width="small"),
|
||||
"처리 행": st.column_config.NumberColumn(width="small"),
|
||||
"오류 코드": st.column_config.TextColumn(width="medium"),
|
||||
}
|
||||
if show_details:
|
||||
column_config["메시지"] = st.column_config.TextColumn(width="large")
|
||||
with st.container(key="poc4_hmm_audit_event_panel"):
|
||||
st.dataframe(
|
||||
display_rows,
|
||||
column_config=column_config,
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(640, 104 + 38 * len(display_rows)),
|
||||
)
|
||||
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""Small localhost authentication service for the HMM Streamlit portal.
|
||||
|
||||
Nginx owns the public security boundary. This module validates the existing
|
||||
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
|
||||
subrequests. Authentication values are never accepted from a URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Deque
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
def _environment_value(*names: str, default: str = "") -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
if value is not None and value.strip():
|
||||
return value.strip()
|
||||
return default
|
||||
|
||||
|
||||
LOG = logging.getLogger("hmm_portal_auth")
|
||||
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
|
||||
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
|
||||
SESSION_TOKEN_VERSION = 2
|
||||
MAX_REQUEST_BYTES = 8_192
|
||||
MAX_FIELD_CHARS = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
username: str
|
||||
password_pbkdf2: str
|
||||
cookie_secret: str
|
||||
bind_address: str = "127.0.0.1"
|
||||
port: int = 8621
|
||||
session_seconds: int = 12 * 60 * 60
|
||||
remember_seconds: int = 7 * 24 * 60 * 60
|
||||
product_name: str = "HMM AI 업무 에이전트"
|
||||
login_title: str = "HMM AI 업무 에이전트"
|
||||
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
|
||||
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
|
||||
primary_color: str = "#004b87"
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "AuthConfig":
|
||||
config = cls(
|
||||
username=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
|
||||
),
|
||||
password_pbkdf2=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
|
||||
"POC4_LOGIN_PASSWORD_PBKDF2",
|
||||
),
|
||||
cookie_secret=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
|
||||
"POC4_LOGIN_COOKIE_SECRET",
|
||||
),
|
||||
bind_address=os.environ.get(
|
||||
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
|
||||
).strip(),
|
||||
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
|
||||
session_seconds=int(
|
||||
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
|
||||
),
|
||||
remember_seconds=int(
|
||||
os.environ.get(
|
||||
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
|
||||
)
|
||||
),
|
||||
product_name=os.environ.get(
|
||||
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
|
||||
).strip(),
|
||||
login_title=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
|
||||
).strip(),
|
||||
login_description=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
|
||||
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
|
||||
).strip(),
|
||||
login_footer=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_FOOTER",
|
||||
"승인된 사용자만 접속할 수 있습니다.",
|
||||
).strip(),
|
||||
primary_color=os.environ.get(
|
||||
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
|
||||
).strip(),
|
||||
)
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.username or not self.password_pbkdf2:
|
||||
raise ValueError("AI web agent console login credentials are not configured")
|
||||
if len(self.cookie_secret.encode("utf-8")) < 32:
|
||||
raise ValueError(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes"
|
||||
)
|
||||
if self.bind_address not in {"127.0.0.1", "::1"}:
|
||||
raise ValueError("Portal authentication service must bind to loopback")
|
||||
if not 1 <= self.port <= 65535:
|
||||
raise ValueError("PORTAL_AUTH_PORT is invalid")
|
||||
if not 300 <= self.session_seconds <= 24 * 60 * 60:
|
||||
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
|
||||
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
|
||||
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedSession:
|
||||
username: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class SessionTokenCodec:
|
||||
def __init__(self, secret: str):
|
||||
self._secret = secret.encode("utf-8")
|
||||
|
||||
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
|
||||
issued_at = int(time.time()) if now is None else now
|
||||
payload = {
|
||||
"v": SESSION_TOKEN_VERSION,
|
||||
"u": username,
|
||||
"i": issued_at,
|
||||
"e": issued_at + lifetime_seconds,
|
||||
"n": secrets.token_urlsafe(18),
|
||||
}
|
||||
encoded = _base64url_encode(
|
||||
json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
|
||||
if not token or len(token) > 2048:
|
||||
return None
|
||||
current_time = int(time.time()) if now is None else now
|
||||
try:
|
||||
encoded, supplied_signature = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
return None
|
||||
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
|
||||
version = int(payload["v"])
|
||||
username = str(payload["u"])
|
||||
issued_at = int(payload["i"])
|
||||
expires_at = int(payload["e"])
|
||||
except (
|
||||
binascii.Error,
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeDecodeError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
return None
|
||||
if version != SESSION_TOKEN_VERSION:
|
||||
return None
|
||||
if issued_at > current_time + 30 or expires_at <= current_time:
|
||||
return None
|
||||
if expires_at - issued_at > 30 * 24 * 60 * 60:
|
||||
return None
|
||||
if not hmac.compare_digest(username, expected_username):
|
||||
return None
|
||||
return AuthenticatedSession(username=username, expires_at=expires_at)
|
||||
|
||||
|
||||
class LoginAttemptLimiter:
|
||||
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
|
||||
self._maximum_failures = maximum_failures
|
||||
self._window_seconds = window_seconds
|
||||
self._failures: dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def blocked(self, key: str, now: float | None = None) -> bool:
|
||||
current_time = time.monotonic() if now is None else now
|
||||
with self._lock:
|
||||
failures = self._failures[key]
|
||||
self._prune(failures, current_time)
|
||||
return len(failures) >= self._maximum_failures
|
||||
|
||||
def record_failure(self, key: str, now: float | None = None) -> None:
|
||||
current_time = time.monotonic() if now is None else now
|
||||
with self._lock:
|
||||
failures = self._failures[key]
|
||||
self._prune(failures, current_time)
|
||||
failures.append(current_time)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def _prune(self, failures: Deque[float], now: float) -> None:
|
||||
cutoff = now - self._window_seconds
|
||||
while failures and failures[0] < cutoff:
|
||||
failures.popleft()
|
||||
|
||||
|
||||
def password_matches(password: str, encoded_password: str) -> bool:
|
||||
try:
|
||||
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split(
|
||||
"$", 3
|
||||
)
|
||||
iterations = int(iterations_text)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(expected_hex)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
|
||||
return False
|
||||
candidate = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, iterations
|
||||
)
|
||||
return hmac.compare_digest(candidate, expected)
|
||||
|
||||
|
||||
def session_cookie_header(token: str, max_age: int | None) -> str:
|
||||
attributes = [
|
||||
f"{SESSION_COOKIE_NAME}={token}",
|
||||
"Path=/",
|
||||
"Secure",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
]
|
||||
if max_age is not None:
|
||||
attributes.append(f"Max-Age={max_age}")
|
||||
return "; ".join(attributes)
|
||||
|
||||
|
||||
def clear_session_cookie_header() -> str:
|
||||
return (
|
||||
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
||||
"Secure; HttpOnly; SameSite=Lax"
|
||||
)
|
||||
|
||||
|
||||
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
|
||||
return (
|
||||
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
|
||||
"Secure; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
|
||||
|
||||
def clear_csrf_cookie_header() -> str:
|
||||
return (
|
||||
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
||||
"Secure; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
|
||||
|
||||
def _base64url_encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _base64url_decode(value: str) -> bytes:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def _cookie_value(cookie_header: str, name: str) -> str:
|
||||
try:
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(cookie_header)
|
||||
morsel = cookies.get(name)
|
||||
return morsel.value if morsel is not None else ""
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
|
||||
error_html = (
|
||||
f'<div class="error" role="alert">{html.escape(error)}</div>'
|
||||
if error
|
||||
else ""
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{html.escape(config.product_name)}</title>
|
||||
<style>
|
||||
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
|
||||
--muted:#60717f; --border:#d9e0e5; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#fff; color:var(--text);
|
||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
||||
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
|
||||
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
|
||||
letter-spacing:.08em; }}
|
||||
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
|
||||
.description,.footer {{ color:var(--muted); line-height:1.55; }}
|
||||
form {{ margin-top:28px; }}
|
||||
label {{ display:block; margin:0 0 18px; font-weight:700; }}
|
||||
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
|
||||
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
|
||||
font:inherit; color:var(--text); background:#fff; }}
|
||||
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
|
||||
.remember input {{ width:17px; height:17px; }}
|
||||
button {{ width:100%; padding:12px; border:1px solid var(--primary);
|
||||
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
|
||||
font-weight:800; cursor:pointer; }}
|
||||
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
|
||||
border-radius:5px; color:#8a2222; background:#fff7f7; }}
|
||||
.footer {{ margin-top:22px; font-size:.9rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="wordmark">HMM</div>
|
||||
<h1>{html.escape(config.login_title)}</h1>
|
||||
<p class="description">{html.escape(config.login_description)}</p>
|
||||
{error_html}
|
||||
<form action="/auth/login" method="post" autocomplete="on">
|
||||
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
|
||||
<label>사용자 ID
|
||||
<input name="username" type="text" maxlength="80" autocomplete="username"
|
||||
required autofocus>
|
||||
</label>
|
||||
<label>비밀번호
|
||||
<input name="password" type="password" maxlength="200"
|
||||
autocomplete="current-password" required>
|
||||
</label>
|
||||
<label class="remember">
|
||||
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
|
||||
</label>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
<p class="footer">{html.escape(config.login_footer)}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
|
||||
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
|
||||
codec = SessionTokenCodec(config.cookie_secret)
|
||||
limiter = LoginAttemptLimiter()
|
||||
|
||||
class PortalAuthHandler(BaseHTTPRequestHandler):
|
||||
server_version = "HMMPortalAuth/1.0"
|
||||
sys_version = ""
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self._route(send_body=False)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._route(send_body=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/auth/login":
|
||||
self._login()
|
||||
elif path == "/auth/logout":
|
||||
self._logout()
|
||||
else:
|
||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
|
||||
|
||||
def _route(self, send_body: bool) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/auth/check":
|
||||
self._check()
|
||||
elif path == "/auth/login":
|
||||
self._show_login(send_body=send_body)
|
||||
elif path == "/auth/logout":
|
||||
self._logout()
|
||||
elif path == "/auth/healthz":
|
||||
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
|
||||
else:
|
||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
|
||||
|
||||
def _check(self) -> None:
|
||||
session = self._session()
|
||||
if session is None:
|
||||
self._send_empty(HTTPStatus.UNAUTHORIZED)
|
||||
return
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._security_headers()
|
||||
self.send_header("X-Auth-User", session.username)
|
||||
self.send_header("X-Auth-Expires", str(session.expires_at))
|
||||
self.end_headers()
|
||||
|
||||
def _show_login(self, send_body: bool = True, error: str = "") -> None:
|
||||
if self._session() is not None and not error:
|
||||
self._redirect("/")
|
||||
return
|
||||
csrf_value = secrets.token_urlsafe(32)
|
||||
body = _login_page(config, csrf_value, error)
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _login(self) -> None:
|
||||
client_key = self._client_key()
|
||||
if limiter.blocked(client_key):
|
||||
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
if not 1 <= content_length <= MAX_REQUEST_BYTES:
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
raw_body = self.rfile.read(content_length)
|
||||
try:
|
||||
form = parse_qs(
|
||||
raw_body.decode("utf-8"),
|
||||
keep_blank_values=True,
|
||||
strict_parsing=False,
|
||||
max_num_fields=8,
|
||||
)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
username = _form_value(form, "username")
|
||||
password = _form_value(form, "password")
|
||||
csrf_form = _form_value(form, "csrf")
|
||||
csrf_cookie = _cookie_value(
|
||||
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
|
||||
)
|
||||
if (
|
||||
not csrf_form
|
||||
or not csrf_cookie
|
||||
or not hmac.compare_digest(csrf_form, csrf_cookie)
|
||||
):
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
valid_credentials = (
|
||||
len(username) <= 80
|
||||
and len(password) <= MAX_FIELD_CHARS
|
||||
and hmac.compare_digest(username.strip(), config.username)
|
||||
and password_matches(password, config.password_pbkdf2)
|
||||
)
|
||||
if not valid_credentials:
|
||||
limiter.record_failure(client_key)
|
||||
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
|
||||
return
|
||||
limiter.reset(client_key)
|
||||
remember = _form_value(form, "remember") == "yes"
|
||||
lifetime = (
|
||||
config.remember_seconds if remember else config.session_seconds
|
||||
)
|
||||
token = codec.issue(config.username, lifetime)
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", "/")
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
session_cookie_header(token, lifetime if remember else None),
|
||||
)
|
||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
||||
self.end_headers()
|
||||
|
||||
def _logout(self) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", "/auth/login")
|
||||
self.send_header("Set-Cookie", clear_session_cookie_header())
|
||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
||||
self.end_headers()
|
||||
|
||||
def _session(self) -> AuthenticatedSession | None:
|
||||
token = _cookie_value(
|
||||
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
|
||||
)
|
||||
return codec.verify(token, config.username)
|
||||
|
||||
def _client_key(self) -> str:
|
||||
forwarded = self.headers.get("X-Real-IP", "").strip()
|
||||
return forwarded or self.client_address[0]
|
||||
|
||||
def _redirect(self, location: str) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
|
||||
def _send_empty(self, status: HTTPStatus) -> None:
|
||||
self.send_response(status)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def _send_text(
|
||||
self,
|
||||
status: HTTPStatus,
|
||||
message: str,
|
||||
send_body: bool = True,
|
||||
) -> None:
|
||||
body = message.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||
)
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
# Do not log query strings, cookies, form bodies, or tokens.
|
||||
LOG.info("%s %s", self.command, urlsplit(self.path).path)
|
||||
|
||||
return PortalAuthHandler
|
||||
|
||||
|
||||
def _form_value(form: dict[str, list[str]], name: str) -> str:
|
||||
values = form.get(name)
|
||||
return values[0] if values else ""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
config = AuthConfig.from_environment()
|
||||
server = ThreadingHTTPServer(
|
||||
(config.bind_address, config.port), build_handler(config)
|
||||
)
|
||||
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Pure helpers for MCP result envelopes used by the Streamlit console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def response_payload(value: Any) -> Mapping[str, Any]:
|
||||
"""Return the business payload from a direct or nested MCP response."""
|
||||
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
nested = value.get("response")
|
||||
return nested if isinstance(nested, Mapping) else value
|
||||
|
||||
|
||||
def text_result(value: Any) -> str:
|
||||
"""Return a textual `result` field without stringifying other structures."""
|
||||
|
||||
result = response_payload(value).get("result")
|
||||
return result.strip() if isinstance(result, str) else ""
|
||||
|
||||
|
||||
def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]:
|
||||
"""Build a safe UI summary for status/result-style compatibility tools."""
|
||||
|
||||
payload = response_payload(value)
|
||||
summary: dict[str, Any] = {}
|
||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||
item = payload.get(key)
|
||||
if item not in (None, "", []):
|
||||
summary[key] = item
|
||||
result = text_result(value)
|
||||
if result:
|
||||
summary["result_chars"] = len(result)
|
||||
summary["result_excerpt"] = result[:excerpt_chars] + (
|
||||
"..." if len(result) > excerpt_chars else ""
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]:
|
||||
"""Preserve bounded textual policy/data evidence for final answer synthesis."""
|
||||
|
||||
payload = response_payload(value)
|
||||
evidence: dict[str, Any] = {}
|
||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||
item = payload.get(key)
|
||||
if item not in (None, "", []):
|
||||
evidence[key] = item
|
||||
result = text_result(value)
|
||||
if result:
|
||||
evidence["result"] = result[:max_chars] + (
|
||||
"..." if len(result) > max_chars else ""
|
||||
)
|
||||
evidence["result_chars"] = len(result)
|
||||
return evidence
|
||||
|
||||
|
||||
def has_actionable_text_result(value: Any) -> bool:
|
||||
"""Return whether a textual result contains evidence worth stopping on."""
|
||||
|
||||
result = text_result(value)
|
||||
if not result:
|
||||
return False
|
||||
normalized = " ".join(result.casefold().split())
|
||||
return not any(
|
||||
marker in normalized
|
||||
for marker in ("no data found", "no evidence found", "error:")
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"has_actionable_text_result",
|
||||
"response_payload",
|
||||
"status_result_evidence",
|
||||
"status_result_summary",
|
||||
"text_result",
|
||||
]
|
||||
196
ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py
Normal file
196
ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""LLM-based MCP tool routing.
|
||||
|
||||
The router receives only the user question and the discovered MCP tool
|
||||
descriptors. It never receives MCP bearer tokens or provider credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ai_web_agent_console.oci_genai_sdk import (
|
||||
build_oci_genai_completion_client,
|
||||
temperature_for_model_profile,
|
||||
)
|
||||
from ai_web_agent_console.model_registry import resolve_model_profile
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpTool:
|
||||
name: str
|
||||
description: str
|
||||
schema: Mapping[str, Any]
|
||||
read_only: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutedMcpTool:
|
||||
server_id: str
|
||||
tool: McpTool
|
||||
|
||||
|
||||
class McpToolRouterError(RuntimeError):
|
||||
"""Safe routing error. Must not contain secrets or provider traces."""
|
||||
|
||||
|
||||
def route_mcp_tool_across_servers_with_llm(
|
||||
tools: list[RoutedMcpTool],
|
||||
question: str,
|
||||
*,
|
||||
router_model_profile: str,
|
||||
) -> RoutedMcpTool:
|
||||
"""Select one discovered MCP server/tool pair with OCI GenAI."""
|
||||
|
||||
candidates = list(tools)
|
||||
if not candidates:
|
||||
raise McpToolRouterError("라우팅 가능한 MCP tool이 없습니다.")
|
||||
|
||||
by_key = {
|
||||
"{}::{}".format(candidate.server_id, candidate.tool.name): candidate
|
||||
for candidate in candidates
|
||||
}
|
||||
route_keys = list(by_key)
|
||||
try:
|
||||
profile = resolve_model_profile(router_model_profile)
|
||||
client = build_oci_genai_completion_client(
|
||||
profile.model_id,
|
||||
profile.answer_model_region,
|
||||
profile.answer_model_endpoint,
|
||||
)
|
||||
tool_catalog = [
|
||||
{
|
||||
"route_key": "{}::{}".format(candidate.server_id, candidate.tool.name),
|
||||
"server_id": candidate.server_id,
|
||||
"tool_name": candidate.tool.name,
|
||||
"description": candidate.tool.description[:1000],
|
||||
"input_properties": sorted(
|
||||
(
|
||||
candidate.tool.schema.get("properties", {})
|
||||
if isinstance(
|
||||
candidate.tool.schema.get("properties"), Mapping
|
||||
)
|
||||
else {}
|
||||
).keys()
|
||||
),
|
||||
"read_only": candidate.tool.read_only,
|
||||
}
|
||||
for candidate in candidates
|
||||
]
|
||||
text = client.complete(
|
||||
system_prompt=(
|
||||
"You are an MCP server and tool router. Choose exactly one "
|
||||
"server/tool route for the user question from the discovered "
|
||||
"routes. Return only JSON that matches the schema. Never "
|
||||
"request or expose bearer tokens. Do not invent server ids or "
|
||||
"tool names."
|
||||
),
|
||||
user_prompt=json.dumps(
|
||||
{
|
||||
"question": question,
|
||||
"routes": tool_catalog,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
response_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": ["route_key"],
|
||||
"properties": {
|
||||
"route_key": {
|
||||
"type": "string",
|
||||
"enum": route_keys,
|
||||
}
|
||||
},
|
||||
},
|
||||
max_tokens=256,
|
||||
temperature=temperature_for_model_profile(profile),
|
||||
)
|
||||
routed = json.loads(text)
|
||||
except Exception:
|
||||
raise McpToolRouterError("LLM tool router 호출에 실패했습니다.") from None
|
||||
|
||||
if not isinstance(routed, Mapping):
|
||||
raise McpToolRouterError("LLM tool router 응답 형식이 올바르지 않습니다.")
|
||||
selected_key = str(routed.get("route_key") or "").strip()
|
||||
selected = by_key.get(selected_key)
|
||||
if selected is None:
|
||||
raise McpToolRouterError("LLM tool router가 허용되지 않은 route를 선택했습니다.")
|
||||
return selected
|
||||
|
||||
|
||||
def route_mcp_tool_with_llm(
|
||||
tools: list[McpTool],
|
||||
question: str,
|
||||
*,
|
||||
preferred_tool: str,
|
||||
tool_allowlist: tuple[str, ...],
|
||||
router_model_profile: str,
|
||||
) -> McpTool:
|
||||
"""Backward-compatible single-server routing helper."""
|
||||
|
||||
candidates = [
|
||||
tool for tool in tools if not tool_allowlist or tool.name in tool_allowlist
|
||||
]
|
||||
routed = route_mcp_tool_across_servers_with_llm(
|
||||
[RoutedMcpTool(server_id="default", tool=tool) for tool in candidates],
|
||||
question,
|
||||
router_model_profile=router_model_profile,
|
||||
)
|
||||
return routed.tool
|
||||
|
||||
|
||||
def build_mcp_tool_arguments(
|
||||
tool: McpTool,
|
||||
question: str,
|
||||
limit: int,
|
||||
*,
|
||||
preferred_tool: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build bounded tool arguments from the selected tool schema."""
|
||||
|
||||
properties = tool.schema.get("properties")
|
||||
if not isinstance(properties, Mapping):
|
||||
properties = {}
|
||||
|
||||
# A server's default/preferred tool still has to obey its discovered schema.
|
||||
# HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape
|
||||
# makes an otherwise valid tool fail argument validation.
|
||||
del preferred_tool
|
||||
input_name = next(
|
||||
(name for name in ("prompt", "question", "query", "term", "text") if name in properties),
|
||||
"",
|
||||
)
|
||||
if not input_name:
|
||||
required = tool.schema.get("required")
|
||||
if isinstance(required, list):
|
||||
input_name = next(
|
||||
(
|
||||
str(name)
|
||||
for name in required
|
||||
if isinstance(properties.get(str(name)), Mapping)
|
||||
and properties[str(name)].get("type") == "string"
|
||||
),
|
||||
"",
|
||||
)
|
||||
if input_name:
|
||||
args: dict[str, Any] = {input_name: question}
|
||||
if "max_evidence" in properties:
|
||||
args["max_evidence"] = min(limit, 10)
|
||||
elif "max_rows" in properties:
|
||||
args["max_rows"] = limit
|
||||
elif "limit" in properties:
|
||||
args["limit"] = limit
|
||||
return args
|
||||
return {"prompt": question, "limit": limit}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"McpTool",
|
||||
"McpToolRouterError",
|
||||
"RoutedMcpTool",
|
||||
"build_mcp_tool_arguments",
|
||||
"route_mcp_tool_across_servers_with_llm",
|
||||
"route_mcp_tool_with_llm",
|
||||
]
|
||||
434
ai-web-agent-console/ai_web_agent_console/model_registry.py
Normal file
434
ai-web-agent-console/ai_web_agent_console/model_registry.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""8512/8513 전용 AI Web Agent Console model profile registry.
|
||||
|
||||
이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며
|
||||
``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재
|
||||
MCP 실행 경로는 계속 ``mock``일 수 있다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = ROOT / "config" / "model_profiles.json"
|
||||
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
|
||||
MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE"
|
||||
MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT"
|
||||
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
|
||||
MODEL_PROFILE_ALIASES = {
|
||||
"gpt54_mini": "gpt54_mini_oci",
|
||||
"llama33": "llama33_70b",
|
||||
}
|
||||
EXPECTED_SOURCE_TAG = "poc_2-gpt55-oci-partial"
|
||||
EXPECTED_SOURCE_COMMIT = "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f"
|
||||
_EXPECTED_ANSWER_MODEL_ROUTES = {
|
||||
"gpt55_oci": (
|
||||
"openai.gpt-5.5",
|
||||
"us-chicago-1",
|
||||
"OPENAI_GPT_5_5_CHAT",
|
||||
"OCI_REGIONAL_DEFAULT",
|
||||
),
|
||||
"gpt54_mini_oci": (
|
||||
"openai.gpt-5.4-mini",
|
||||
"us-chicago-1",
|
||||
"OPENAI_GPT_5_4_MINI_CHAT",
|
||||
"OCI_REGIONAL_DEFAULT",
|
||||
),
|
||||
"grok43": (
|
||||
"xai.grok-4.3",
|
||||
"us-chicago-1",
|
||||
"XAI_GROK_4_3_CHAT",
|
||||
"OCI_REGIONAL_DEFAULT",
|
||||
),
|
||||
"llama4_maverick": (
|
||||
"meta.llama-4-maverick-17b-128e-instruct-fp8",
|
||||
"us-chicago-1",
|
||||
"META_LLAMA_4_MAVERICK_CHAT",
|
||||
"OCI_REGIONAL_DEFAULT",
|
||||
),
|
||||
"llama33_70b": (
|
||||
"meta.llama-3.3-70b-instruct",
|
||||
"us-chicago-1",
|
||||
"META_LLAMA_3_3_70B_CHAT",
|
||||
"OCI_REGIONAL_DEFAULT",
|
||||
),
|
||||
}
|
||||
|
||||
_MODEL_KEY = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
|
||||
_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_ANSWER_MODEL_ID_ALIAS = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$")
|
||||
_OCI_REGION = re.compile(r"^[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*$")
|
||||
_OCI_REGIONAL_ENDPOINT = re.compile(
|
||||
r"^https://inference\.generativeai\."
|
||||
r"(?P<region>[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*)\.oci\.oraclecloud\.com$"
|
||||
)
|
||||
_ORACLE_IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9_$#]{0,127}$")
|
||||
_SOURCE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$")
|
||||
_VERIFICATION_STATUSES = frozenset(
|
||||
{"VERIFIED", "PARTIAL_VERIFIED", "VERIFIED_WITH_WARNINGS"}
|
||||
)
|
||||
_REQUIRED_PROFILE_FIELDS = (
|
||||
"model_key",
|
||||
"display_name",
|
||||
"provider",
|
||||
"model_id",
|
||||
"answer_model_id_alias",
|
||||
"answer_model_region",
|
||||
"answer_model_endpoint_mode",
|
||||
"poc2_select_ai_profile",
|
||||
"poc2_native_agent_team",
|
||||
"verification_status",
|
||||
"default_for_console",
|
||||
"source_tag",
|
||||
"notes",
|
||||
)
|
||||
|
||||
_PROFILE_ROUTE_ENV_KEYS = {
|
||||
"gpt55_oci": (
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT",
|
||||
),
|
||||
"gpt54_mini_oci": (
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT",
|
||||
),
|
||||
"grok43": (
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT",
|
||||
),
|
||||
"llama4_maverick": (
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT",
|
||||
),
|
||||
"llama33_70b": (
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _regional_endpoint(region: str) -> str:
|
||||
return "https://inference.generativeai.%s.oci.oraclecloud.com" % region
|
||||
|
||||
|
||||
def _endpoint_host_alias(region: str) -> str:
|
||||
return "OCI_GENAI_INFERENCE_%s" % region.upper().replace("-", "_")
|
||||
|
||||
|
||||
def _env_override(
|
||||
environ: Mapping[str, str],
|
||||
key: str,
|
||||
default: str,
|
||||
) -> str:
|
||||
if key not in environ:
|
||||
return default
|
||||
value = environ.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("answer model route override is invalid")
|
||||
return value.strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelProfile:
|
||||
"""UI와 workflow가 공유하는 비밀값 없는 model metadata."""
|
||||
|
||||
model_key: str
|
||||
display_name: str
|
||||
provider: str
|
||||
model_id: str
|
||||
answer_model_id_alias: str
|
||||
answer_model_region: str
|
||||
answer_model_endpoint_mode: str
|
||||
answer_model_endpoint: str = field(repr=False)
|
||||
poc2_select_ai_profile: str
|
||||
poc2_native_agent_team: str
|
||||
verification_status: str
|
||||
default_for_console: bool
|
||||
source_tag: str
|
||||
notes: str
|
||||
display_order: int = 999
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
|
||||
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
|
||||
if missing:
|
||||
raise ValueError("AI Web Agent Console model profile fields are missing")
|
||||
if not isinstance(value.get("default_for_console"), bool):
|
||||
raise ValueError("default_for_console must be boolean")
|
||||
order = value.get("display_order", 999)
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
|
||||
raise ValueError("model profile display_order is invalid")
|
||||
|
||||
answer_model_region = str(value["answer_model_region"]).strip().lower()
|
||||
profile = cls(
|
||||
model_key=str(value["model_key"]).strip().lower(),
|
||||
display_name=str(value["display_name"]).strip(),
|
||||
provider=str(value["provider"]).strip().lower(),
|
||||
model_id=str(value["model_id"]).strip(),
|
||||
answer_model_id_alias=str(value["answer_model_id_alias"])
|
||||
.strip()
|
||||
.upper(),
|
||||
answer_model_region=answer_model_region,
|
||||
answer_model_endpoint_mode=str(
|
||||
value["answer_model_endpoint_mode"]
|
||||
).strip().upper(),
|
||||
answer_model_endpoint=_regional_endpoint(answer_model_region),
|
||||
poc2_select_ai_profile=str(value["poc2_select_ai_profile"])
|
||||
.strip()
|
||||
.upper(),
|
||||
poc2_native_agent_team=str(value["poc2_native_agent_team"])
|
||||
.strip()
|
||||
.upper(),
|
||||
verification_status=str(value["verification_status"]).strip().upper(),
|
||||
default_for_console=value["default_for_console"],
|
||||
source_tag=str(value["source_tag"]).strip(),
|
||||
notes=str(value["notes"]).strip(),
|
||||
display_order=order,
|
||||
)
|
||||
if not _MODEL_KEY.fullmatch(profile.model_key):
|
||||
raise ValueError("model profile key is invalid")
|
||||
if not profile.display_name or len(profile.display_name) > 128:
|
||||
raise ValueError("model profile display name is invalid")
|
||||
if profile.provider != "oci":
|
||||
raise ValueError("unsupported model provider")
|
||||
if not _MODEL_ID.fullmatch(profile.model_id):
|
||||
raise ValueError("model id is invalid")
|
||||
if not _ANSWER_MODEL_ID_ALIAS.fullmatch(profile.answer_model_id_alias):
|
||||
raise ValueError("answer model id alias is invalid")
|
||||
if not _OCI_REGION.fullmatch(profile.answer_model_region):
|
||||
raise ValueError("answer model region is invalid")
|
||||
if profile.answer_model_endpoint_mode != "OCI_REGIONAL_DEFAULT":
|
||||
raise ValueError("answer model endpoint mode is invalid")
|
||||
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(
|
||||
profile.answer_model_endpoint
|
||||
)
|
||||
if (
|
||||
endpoint_match is None
|
||||
or endpoint_match.group("region") != profile.answer_model_region
|
||||
):
|
||||
raise ValueError("answer model endpoint is invalid")
|
||||
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_select_ai_profile):
|
||||
raise ValueError("PoC_2 Select AI profile mapping is invalid")
|
||||
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_native_agent_team):
|
||||
raise ValueError("PoC_2 Native Agent team mapping is invalid")
|
||||
if profile.verification_status not in _VERIFICATION_STATUSES:
|
||||
raise ValueError("model verification status is invalid")
|
||||
if not _SOURCE_TAG.fullmatch(profile.source_tag):
|
||||
raise ValueError("model profile source tag is invalid")
|
||||
if not profile.notes or len(profile.notes) > 1_000:
|
||||
raise ValueError("model profile notes are invalid")
|
||||
return profile
|
||||
|
||||
def with_answer_route_overrides(
|
||||
self,
|
||||
environ: Mapping[str, str],
|
||||
) -> "ModelProfile":
|
||||
"""Apply only this profile's validated, non-secret OCI route settings."""
|
||||
|
||||
keys = _PROFILE_ROUTE_ENV_KEYS.get(self.model_key)
|
||||
if keys is None:
|
||||
raise ValueError("answer model route is not registered")
|
||||
model_id = _env_override(environ, keys[0], self.model_id)
|
||||
region = _env_override(environ, keys[1], self.answer_model_region).lower()
|
||||
endpoint = _env_override(
|
||||
environ,
|
||||
keys[2],
|
||||
_regional_endpoint(region),
|
||||
)
|
||||
if not _MODEL_ID.fullmatch(model_id):
|
||||
raise ValueError("answer model route override is invalid")
|
||||
if not _OCI_REGION.fullmatch(region):
|
||||
raise ValueError("answer model route override is invalid")
|
||||
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(endpoint)
|
||||
if endpoint_match is None or endpoint_match.group("region") != region:
|
||||
raise ValueError("answer model route override is invalid")
|
||||
return replace(
|
||||
self,
|
||||
model_id=model_id,
|
||||
answer_model_region=region,
|
||||
answer_model_endpoint=endpoint,
|
||||
)
|
||||
|
||||
def public_metadata(self) -> dict[str, object]:
|
||||
"""System Details에 투영 가능한 비밀값 없는 metadata를 반환한다."""
|
||||
|
||||
return {
|
||||
"model_key": self.model_key,
|
||||
"display_name": self.display_name,
|
||||
"provider": self.provider,
|
||||
"answer_model_id_alias": self.answer_model_id_alias,
|
||||
"answer_model_region": self.answer_model_region,
|
||||
"answer_model_endpoint_mode": self.answer_model_endpoint_mode,
|
||||
"answer_model_endpoint_host_alias": _endpoint_host_alias(
|
||||
self.answer_model_region
|
||||
),
|
||||
"poc2_select_ai_profile": self.poc2_select_ai_profile,
|
||||
"poc2_native_agent_team": self.poc2_native_agent_team,
|
||||
"verification_status": self.verification_status,
|
||||
"default_for_console": self.default_for_console,
|
||||
"source_tag": self.source_tag,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelProfileRegistry:
|
||||
"""검증된 AI Web Agent Console model profile 집합."""
|
||||
|
||||
profiles: tuple[ModelProfile, ...]
|
||||
default_model_profile: str
|
||||
registry_name: str
|
||||
source_commit: str
|
||||
schema_version: int = 1
|
||||
|
||||
def by_key(self, model_key: object) -> ModelProfile:
|
||||
candidate = str(model_key or "").strip().lower()
|
||||
candidate = MODEL_PROFILE_ALIASES.get(candidate, candidate)
|
||||
for profile in self.profiles:
|
||||
if profile.model_key == candidate:
|
||||
return profile
|
||||
# 사용자 입력이나 환경변수 원문을 오류에 반사하지 않는다.
|
||||
raise ValueError("model profile is not registered")
|
||||
|
||||
@property
|
||||
def default_profile(self) -> ModelProfile:
|
||||
return self.by_key(self.default_model_profile)
|
||||
|
||||
def selector_options(self) -> tuple[ModelProfile, ...]:
|
||||
return tuple(sorted(self.profiles, key=lambda item: item.display_order))
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
requested: object = None,
|
||||
*,
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
) -> ModelProfile:
|
||||
"""명시 요청은 엄격히 검증하고 환경 기본값은 안전하게 fallback한다."""
|
||||
|
||||
source = os.environ if environ is None else environ
|
||||
if requested is not None and str(requested).strip():
|
||||
return self.by_key(requested).with_answer_route_overrides(source)
|
||||
|
||||
for name in (MODEL_PROFILE_ENV, MODEL_PROFILE_DEFAULT_ENV):
|
||||
candidate = source.get(name)
|
||||
if not candidate or not candidate.strip():
|
||||
continue
|
||||
try:
|
||||
profile = self.by_key(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
return profile.with_answer_route_overrides(source)
|
||||
return self.default_profile.with_answer_route_overrides(source)
|
||||
|
||||
|
||||
def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
|
||||
"""JSON registry를 매 호출마다 검증해 반환한다."""
|
||||
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("AI Web Agent Console model profile registry must be an object")
|
||||
raw_profiles = payload.get("profiles")
|
||||
if not isinstance(raw_profiles, list) or not raw_profiles:
|
||||
raise ValueError("AI Web Agent Console model profile registry has no profiles")
|
||||
profiles = tuple(
|
||||
ModelProfile.from_mapping(item)
|
||||
for item in raw_profiles
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
if len(profiles) != len(raw_profiles):
|
||||
raise ValueError("AI Web Agent Console model profile registry contains an invalid profile")
|
||||
keys = tuple(item.model_key for item in profiles)
|
||||
if len(set(keys)) != len(keys):
|
||||
raise ValueError("AI Web Agent Console model profile keys must be unique")
|
||||
if len({item.display_name for item in profiles}) != len(profiles):
|
||||
raise ValueError("AI Web Agent Console model profile display names must be unique")
|
||||
defaults = tuple(item.model_key for item in profiles if item.default_for_console)
|
||||
configured_default = str(payload.get("default_model_profile") or "").strip().lower()
|
||||
if defaults != (configured_default,):
|
||||
raise ValueError("AI Web Agent Console model profile default is inconsistent")
|
||||
if configured_default != DEFAULT_MODEL_PROFILE_KEY:
|
||||
raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied")
|
||||
if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys):
|
||||
raise ValueError("existing AI Web Agent Console selector models are missing")
|
||||
actual_answer_routes = {
|
||||
item.model_key: (
|
||||
item.model_id,
|
||||
item.answer_model_region,
|
||||
item.answer_model_id_alias,
|
||||
item.answer_model_endpoint_mode,
|
||||
)
|
||||
for item in profiles
|
||||
}
|
||||
if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES:
|
||||
raise ValueError("AI Web Agent Console answer model route mapping is inconsistent")
|
||||
if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT:
|
||||
raise ValueError("PoC_2 source commit is inconsistent")
|
||||
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
|
||||
raise ValueError("PoC_2 source tag is inconsistent")
|
||||
if payload.get("schema_version") != 1:
|
||||
raise ValueError("unsupported AI Web Agent Console model profile registry schema")
|
||||
registry_name = str(payload.get("registry_name") or "").strip()
|
||||
if not registry_name:
|
||||
raise ValueError("AI Web Agent Console model profile registry name is missing")
|
||||
return ModelProfileRegistry(
|
||||
profiles=profiles,
|
||||
default_model_profile=configured_default,
|
||||
registry_name=registry_name,
|
||||
source_commit=EXPECTED_SOURCE_COMMIT,
|
||||
)
|
||||
|
||||
|
||||
def resolve_model_profile(
|
||||
requested: object = None,
|
||||
*,
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
) -> ModelProfile:
|
||||
return load_model_registry().resolve(requested, environ=environ)
|
||||
|
||||
|
||||
def resolve_model_profile_key(
|
||||
requested: object = None,
|
||||
*,
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
) -> str:
|
||||
return resolve_model_profile(requested, environ=environ).model_key
|
||||
|
||||
|
||||
def is_registered_model_profile(value: object) -> bool:
|
||||
try:
|
||||
load_model_registry().by_key(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MODEL_PROFILE_KEY",
|
||||
"EXISTING_MODEL_PROFILE_KEYS",
|
||||
"EXPECTED_SOURCE_COMMIT",
|
||||
"EXPECTED_SOURCE_TAG",
|
||||
"MODEL_PROFILE_DEFAULT_ENV",
|
||||
"MODEL_PROFILE_ENV",
|
||||
"MODEL_PROFILE_ALIASES",
|
||||
"ModelProfile",
|
||||
"ModelProfileRegistry",
|
||||
"REGISTRY_PATH",
|
||||
"is_registered_model_profile",
|
||||
"load_model_registry",
|
||||
"resolve_model_profile",
|
||||
"resolve_model_profile_key",
|
||||
]
|
||||
274
ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py
Normal file
274
ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Common OCI Generative AI chat-completion SDK boundary.
|
||||
|
||||
This module is intentionally small: callers provide a validated model route
|
||||
and a JSON schema, and this boundary performs one OCI GenAI chat call. It
|
||||
does not know about MCP, Streamlit, business payloads, or bearer tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Dict, Optional, Protocol
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOTENV_PATH = ROOT / ".env"
|
||||
ALLOWED_OCI_SETTINGS = frozenset(
|
||||
{
|
||||
"OCI_AUTH_TYPE",
|
||||
"OCI_CONFIG_FILE",
|
||||
"OCI_GENAI_COMPARTMENT_ID",
|
||||
"OCI_PROFILE",
|
||||
}
|
||||
)
|
||||
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
class CompletionClient(Protocol):
|
||||
"""Minimal completion client contract shared by app layers."""
|
||||
|
||||
def complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_schema: Mapping[str, object],
|
||||
max_tokens: int,
|
||||
temperature: Optional[float],
|
||||
) -> str:
|
||||
"""Return the assistant message text."""
|
||||
|
||||
|
||||
def read_allowed_dotenv(path: Optional[Path] = None) -> Dict[str, str]:
|
||||
"""Read only non-secret OCI routing/auth-mode settings from .env."""
|
||||
|
||||
selected_path = DOTENV_PATH if path is None else path
|
||||
try:
|
||||
lines = selected_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return {}
|
||||
values: Dict[str, str] = {}
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].lstrip()
|
||||
key, separator, raw_value = line.partition("=")
|
||||
key = key.strip()
|
||||
if not separator or key not in ALLOWED_OCI_SETTINGS:
|
||||
continue
|
||||
value = raw_value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
if "\x00" not in value and "\n" not in value and "\r" not in value:
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class OCISettings:
|
||||
auth_type: str
|
||||
config_file: str
|
||||
profile: str
|
||||
compartment_id: str
|
||||
|
||||
|
||||
def load_oci_settings() -> OCISettings:
|
||||
"""Resolve OCI GenAI settings from safe .env keys and environment."""
|
||||
|
||||
values = read_allowed_dotenv()
|
||||
for key in ALLOWED_OCI_SETTINGS:
|
||||
value = os.environ.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
values[key] = value.strip()
|
||||
|
||||
auth_type = values.get("OCI_AUTH_TYPE", "config_file").strip().casefold()
|
||||
auth_type = auth_type.replace("-", "_")
|
||||
if auth_type in {"api_key", "config", "config_file"}:
|
||||
auth_type = "config_file"
|
||||
elif auth_type not in {"instance_principal", "resource_principal"}:
|
||||
raise ValueError("unsupported OCI authentication mode")
|
||||
|
||||
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
||||
if not _COMPARTMENT_ID.fullmatch(compartment_id):
|
||||
raise ValueError("OCI Generative AI compartment is not configured")
|
||||
return OCISettings(
|
||||
auth_type=auth_type,
|
||||
config_file=values.get("OCI_CONFIG_FILE", "~/.oci/config").strip(),
|
||||
profile=values.get("OCI_PROFILE", "DEFAULT").strip() or "DEFAULT",
|
||||
compartment_id=compartment_id,
|
||||
)
|
||||
|
||||
|
||||
class OCICompletionClient:
|
||||
"""Minimal OCI GenericChatRequest adapter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: OCISettings,
|
||||
model_id: str,
|
||||
region: str,
|
||||
endpoint: str,
|
||||
) -> None:
|
||||
try:
|
||||
import oci
|
||||
from oci.generative_ai_inference import GenerativeAiInferenceClient
|
||||
except (ImportError, AttributeError):
|
||||
raise RuntimeError("OCI SDK is unavailable") from None
|
||||
|
||||
kwargs: Dict[str, object] = {}
|
||||
if settings.auth_type == "config_file":
|
||||
try:
|
||||
config = oci.config.from_file(
|
||||
file_location=os.path.expandvars(
|
||||
os.path.expanduser(settings.config_file)
|
||||
),
|
||||
profile_name=settings.profile,
|
||||
)
|
||||
except Exception:
|
||||
raise RuntimeError("OCI SDK configuration is unavailable") from None
|
||||
config["region"] = region
|
||||
elif settings.auth_type == "instance_principal":
|
||||
try:
|
||||
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
|
||||
except Exception:
|
||||
raise RuntimeError("OCI signer is unavailable") from None
|
||||
config = {"region": region}
|
||||
kwargs["signer"] = signer
|
||||
else:
|
||||
try:
|
||||
signer = oci.auth.signers.get_resource_principals_signer()
|
||||
except Exception:
|
||||
raise RuntimeError("OCI signer is unavailable") from None
|
||||
config = {"region": region}
|
||||
kwargs["signer"] = signer
|
||||
if not config.get("region"):
|
||||
raise RuntimeError("OCI region is unavailable")
|
||||
kwargs["service_endpoint"] = endpoint
|
||||
try:
|
||||
self._client = GenerativeAiInferenceClient(config, **kwargs)
|
||||
except Exception:
|
||||
raise RuntimeError("OCI Generative AI client is unavailable") from None
|
||||
self._compartment_id = settings.compartment_id
|
||||
self._model_id = model_id
|
||||
|
||||
def complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_schema: Mapping[str, object],
|
||||
max_tokens: int,
|
||||
temperature: Optional[float],
|
||||
) -> str:
|
||||
try:
|
||||
from oci.generative_ai_inference.models import (
|
||||
ChatDetails,
|
||||
GenericChatRequest,
|
||||
JsonSchemaResponseFormat,
|
||||
OnDemandServingMode,
|
||||
ResponseJsonSchema,
|
||||
SystemMessage,
|
||||
TextContent,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
schema = ResponseJsonSchema(
|
||||
name="oci_genai_json_response",
|
||||
description="Strict JSON response",
|
||||
schema=dict(response_schema),
|
||||
is_strict=True,
|
||||
)
|
||||
request_options: Dict[str, object] = {
|
||||
"api_format": "GENERIC",
|
||||
"messages": [
|
||||
SystemMessage(content=[TextContent(text=system_prompt)]),
|
||||
UserMessage(content=[TextContent(text=user_prompt)]),
|
||||
],
|
||||
"max_completion_tokens": max_tokens,
|
||||
"is_stream": False,
|
||||
"response_format": JsonSchemaResponseFormat(json_schema=schema),
|
||||
}
|
||||
if temperature is not None:
|
||||
request_options["temperature"] = temperature
|
||||
request = GenericChatRequest(**request_options)
|
||||
details = ChatDetails(
|
||||
compartment_id=self._compartment_id,
|
||||
serving_mode=OnDemandServingMode(model_id=self._model_id),
|
||||
chat_request=request,
|
||||
)
|
||||
response = self._client.chat(details)
|
||||
data = getattr(response, "data", None)
|
||||
chat_response = getattr(data, "chat_response", None)
|
||||
choices = getattr(chat_response, "choices", None)
|
||||
if not isinstance(choices, Sequence) or not choices:
|
||||
raise RuntimeError("OCI response has no choice")
|
||||
message = getattr(choices[0], "message", None)
|
||||
content = getattr(message, "content", None)
|
||||
if not isinstance(content, Sequence) or isinstance(
|
||||
content, (str, bytes, bytearray)
|
||||
) or not content:
|
||||
raise RuntimeError("OCI response has no content")
|
||||
text = getattr(content[0], "text", None)
|
||||
if not isinstance(text, str):
|
||||
raise RuntimeError("OCI response content is invalid")
|
||||
return text
|
||||
except Exception:
|
||||
raise RuntimeError("OCI GenAI completion call failed") from None
|
||||
|
||||
|
||||
def build_oci_genai_completion_client(
|
||||
model_id: str,
|
||||
region: str,
|
||||
endpoint: str,
|
||||
) -> CompletionClient:
|
||||
"""Build a completion client for one validated model route."""
|
||||
|
||||
return _cached_oci_genai_completion_client(
|
||||
load_oci_settings(),
|
||||
model_id,
|
||||
region,
|
||||
endpoint,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _cached_oci_genai_completion_client(
|
||||
settings: OCISettings,
|
||||
model_id: str,
|
||||
region: str,
|
||||
endpoint: str,
|
||||
) -> CompletionClient:
|
||||
"""Reuse OCI GenAI clients within one Python process."""
|
||||
|
||||
return OCICompletionClient(settings, model_id, region, endpoint)
|
||||
|
||||
|
||||
def temperature_for_model_key(model_key: object) -> Optional[float]:
|
||||
"""Return provider-compatible temperature for one registered model key."""
|
||||
|
||||
key = str(model_key or "").strip().lower()
|
||||
if key.startswith("gpt"):
|
||||
return None
|
||||
return 0.1
|
||||
|
||||
|
||||
def temperature_for_model_profile(profile: object) -> Optional[float]:
|
||||
return temperature_for_model_key(getattr(profile, "model_key", profile))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_OCI_SETTINGS",
|
||||
"CompletionClient",
|
||||
"OCICompletionClient",
|
||||
"OCISettings",
|
||||
"build_oci_genai_completion_client",
|
||||
"load_oci_settings",
|
||||
"read_allowed_dotenv",
|
||||
"temperature_for_model_key",
|
||||
"temperature_for_model_profile",
|
||||
]
|
||||
124
ai-web-agent-console/ai_web_agent_console/presentation.py
Normal file
124
ai-web-agent-console/ai_web_agent_console/presentation.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Shared, intentionally small Streamlit presentation primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
from typing import Any
|
||||
|
||||
from .profile import AppProfile
|
||||
|
||||
|
||||
def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
||||
"""Apply one predictable light theme from the product profile."""
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
:root {{ color-scheme: light !important; --console-primary: {escape(profile.primary_color)};
|
||||
--console-text: {escape(profile.text_color)}; --console-muted: {escape(profile.muted_color)};
|
||||
--console-border: {escape(profile.border_color)}; }}
|
||||
html, body, [data-testid="stAppViewContainer"], .stApp {{ background:#fff !important;
|
||||
color:var(--console-text) !important; color-scheme:light !important;
|
||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
||||
header[data-testid="stHeader"] {{ display:none !important; }}
|
||||
.block-container {{ max-width:1180px; padding:2rem 3rem 4rem; }}
|
||||
section[data-testid="stSidebar"], section[data-testid="stSidebar"] > div {{ background:#fff !important; }}
|
||||
section[data-testid="stSidebar"] {{ border-right:1px solid var(--console-border); }}
|
||||
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
|
||||
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
|
||||
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
|
||||
[data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt,
|
||||
[data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote,
|
||||
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
|
||||
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
border:1px solid var(--console-border) !important;
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
div[data-testid="stButton"] > button *,
|
||||
div[data-testid="stFormSubmitButton"] > button * {{
|
||||
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"],
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"] *,
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
|
||||
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
|
||||
/* Streamlit JSON/code components otherwise retain a dark-surface theme while
|
||||
the console text is forced dark, making MCP details unreadable. */
|
||||
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||
[data-testid="stCodeBlock"], [data-testid="stCodeBlock"] > div,
|
||||
[data-testid="stCode"], [data-testid="stCode"] > div {{
|
||||
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; }}
|
||||
[data-testid="stJson"] *, [data-testid="stCodeBlock"] *, [data-testid="stCode"] *,
|
||||
[data-testid="stJson"] pre, [data-testid="stCodeBlock"] pre, [data-testid="stCode"] pre {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
|
||||
background:#fff !important; border-color:var(--console-border) !important; }}
|
||||
[data-testid="stExpander"] summary {{
|
||||
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; }}
|
||||
[data-testid="stExpander"] summary * {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-baseweb="tab-list"], [data-testid="stTabs"] [role="tablist"] {{
|
||||
border-bottom:1px solid var(--console-border) !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"], [data-testid="stTab"] {{
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important;
|
||||
border-radius:4px 4px 0 0 !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"] *, [data-testid="stTab"] * {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"]:hover, [data-testid="stTab"]:hover {{
|
||||
background:#f6f8fa !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"],
|
||||
[data-testid="stTab"][aria-selected="true"] {{
|
||||
color:var(--console-primary) !important;
|
||||
-webkit-text-fill-color:var(--console-primary) !important;
|
||||
border-bottom:3px solid var(--console-primary) !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"] *,
|
||||
[data-testid="stTab"][aria-selected="true"] * {{
|
||||
color:var(--console-primary) !important;
|
||||
-webkit-text-fill-color:var(--console-primary) !important; }}
|
||||
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
||||
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
||||
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
||||
.console-muted {{ color:var(--console-muted) !important; }}
|
||||
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
|
||||
.console-login {{ text-align:left; }}
|
||||
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
|
||||
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
|
||||
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important;
|
||||
border:1px solid var(--console-border); border-radius:4px;
|
||||
text-align:center; text-decoration:none; font-weight:700; }}
|
||||
a.console-logout-button:hover {{ background:#f6f8fa; }}
|
||||
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_console_header(st: Any, profile: AppProfile) -> None:
|
||||
st.markdown(
|
||||
f"""<section class="console-header"><div class="console-wordmark">{escape(profile.short_name)}</div>
|
||||
<h1>{escape(profile.header_title)}</h1><p class="console-muted">{escape(profile.header_description)}</p></section>""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_login_brand(st: Any, profile: AppProfile) -> None:
|
||||
st.markdown(
|
||||
f"""<section class="console-login"><div class="console-wordmark">{escape(profile.short_name)}</div>
|
||||
<p class="console-muted">{escape(profile.login_kicker)}</p><h1>{escape(profile.login_title)}</h1>
|
||||
<p class="console-muted">{escape(profile.login_description)}</p></section>""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
139
ai-web-agent-console/ai_web_agent_console/profile.py
Normal file
139
ai-web-agent-console/ai_web_agent_console/profile.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Configuration-backed product profile for a reusable agent console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class AppProfileError(RuntimeError):
|
||||
"""Safe error for an invalid product profile."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppProfile:
|
||||
product_name: str
|
||||
short_name: str
|
||||
page_title: str
|
||||
page_icon: str
|
||||
header_title: str
|
||||
header_description: str
|
||||
login_kicker: str
|
||||
login_title: str
|
||||
login_description: str
|
||||
login_footer: str
|
||||
primary_color: str
|
||||
text_color: str
|
||||
muted_color: str
|
||||
border_color: str
|
||||
|
||||
|
||||
_ENV_FIELD_NAMES = {
|
||||
"product_name": "AGENT_CONSOLE_NAME",
|
||||
"short_name": "AGENT_CONSOLE_SHORT_NAME",
|
||||
"page_title": "AGENT_CONSOLE_PAGE_TITLE",
|
||||
"page_icon": "AGENT_CONSOLE_PAGE_ICON",
|
||||
"header_title": "AGENT_CONSOLE_HEADER_TITLE",
|
||||
"header_description": "AGENT_CONSOLE_HEADER_DESCRIPTION",
|
||||
"login_kicker": "AGENT_CONSOLE_LOGIN_KICKER",
|
||||
"login_title": "AGENT_CONSOLE_LOGIN_TITLE",
|
||||
"login_description": "AGENT_CONSOLE_LOGIN_DESCRIPTION",
|
||||
"login_footer": "AGENT_CONSOLE_LOGIN_FOOTER",
|
||||
"primary_color": "AGENT_CONSOLE_PRIMARY_COLOR",
|
||||
"text_color": "AGENT_CONSOLE_TEXT_COLOR",
|
||||
"muted_color": "AGENT_CONSOLE_MUTED_COLOR",
|
||||
"border_color": "AGENT_CONSOLE_BORDER_COLOR",
|
||||
}
|
||||
|
||||
|
||||
def _string(section: Mapping[str, Any], key: str, fallback: str = "") -> str:
|
||||
return str(section.get(key) or fallback).strip()
|
||||
|
||||
|
||||
def resolve_profile_path(default_path: Path) -> Path:
|
||||
configured = os.environ.get("AGENT_CONSOLE_PROFILE_PATH", "").strip()
|
||||
if not configured:
|
||||
return default_path
|
||||
path = Path(configured).expanduser()
|
||||
return path if path.is_absolute() else default_path.parent / path
|
||||
|
||||
|
||||
def _dotenv_value(name: str, path: Path | None) -> str:
|
||||
"""Read one simple KEY=value runtime setting without importing a dotenv lib."""
|
||||
|
||||
if path is None:
|
||||
return ""
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError):
|
||||
return ""
|
||||
prefix = f"{name}="
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(prefix):
|
||||
return stripped[len(prefix) :].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_environment_overrides(profile: AppProfile, env_file: Path | None) -> AppProfile:
|
||||
"""Apply deployment-specific presentation values without a code change."""
|
||||
|
||||
values = {
|
||||
field_name: (
|
||||
os.environ.get(env_name, "").strip()
|
||||
or _dotenv_value(env_name, env_file)
|
||||
or getattr(profile, field_name)
|
||||
)
|
||||
for field_name, env_name in _ENV_FIELD_NAMES.items()
|
||||
}
|
||||
return AppProfile(**values)
|
||||
|
||||
|
||||
def load_app_profile(default_path: Path, env_file: Path | None = None) -> AppProfile:
|
||||
"""Load the selectable product skin without coupling it to a PoC name."""
|
||||
|
||||
path = resolve_profile_path(default_path)
|
||||
try:
|
||||
payload: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
raise AppProfileError(f"애플리케이션 프로필을 읽지 못했습니다: {path}") from None
|
||||
if not isinstance(payload, Mapping):
|
||||
raise AppProfileError("애플리케이션 프로필 형식이 올바르지 않습니다.")
|
||||
product = payload.get("product")
|
||||
theme = payload.get("theme")
|
||||
if not isinstance(product, Mapping) or not isinstance(theme, Mapping):
|
||||
raise AppProfileError("애플리케이션 프로필에 product와 theme 객체가 필요합니다.")
|
||||
|
||||
profile = _apply_environment_overrides(AppProfile(
|
||||
product_name=_string(product, "name"),
|
||||
short_name=_string(product, "short_name"),
|
||||
page_title=_string(product, "page_title"),
|
||||
page_icon=_string(product, "page_icon", "🤖"),
|
||||
header_title=_string(product, "header_title"),
|
||||
header_description=_string(product, "header_description"),
|
||||
login_kicker=_string(product, "login_kicker"),
|
||||
login_title=_string(product, "login_title"),
|
||||
login_description=_string(product, "login_description"),
|
||||
login_footer=_string(product, "login_footer"),
|
||||
primary_color=_string(theme, "primary_color"),
|
||||
text_color=_string(theme, "text_color"),
|
||||
muted_color=_string(theme, "muted_color"),
|
||||
border_color=_string(theme, "border_color"),
|
||||
), env_file)
|
||||
required = (
|
||||
profile.product_name,
|
||||
profile.short_name,
|
||||
profile.page_title,
|
||||
profile.header_title,
|
||||
profile.login_title,
|
||||
profile.primary_color,
|
||||
profile.text_color,
|
||||
profile.muted_color,
|
||||
profile.border_color,
|
||||
)
|
||||
if not all(required):
|
||||
raise AppProfileError("애플리케이션 프로필의 필수 표시값 또는 색상이 비어 있습니다.")
|
||||
return profile
|
||||
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Configuration-driven query and answer evidence contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json"
|
||||
|
||||
|
||||
def _contract_file() -> Path:
|
||||
configured = str(
|
||||
os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH")
|
||||
or os.getenv("POC4_QUERY_CONTRACTS_PATH")
|
||||
or ""
|
||||
).strip()
|
||||
if not configured:
|
||||
return DEFAULT_CONTRACT_FILE
|
||||
path = Path(configured).expanduser()
|
||||
return path if path.is_absolute() else ROOT / path
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]:
|
||||
path = Path(path_text)
|
||||
if not path.exists():
|
||||
return ()
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
contracts = value.get("contracts") if isinstance(value, Mapping) else None
|
||||
if not isinstance(contracts, list):
|
||||
raise ValueError("query contracts must contain a contracts array")
|
||||
return tuple(item for item in contracts if isinstance(item, Mapping))
|
||||
|
||||
|
||||
def load_query_contracts() -> tuple[Mapping[str, Any], ...]:
|
||||
return _load_contract_file(str(_contract_file()))
|
||||
|
||||
|
||||
def matching_query_contracts(
|
||||
question: str,
|
||||
tool_name: str,
|
||||
) -> tuple[Mapping[str, Any], ...]:
|
||||
normalized_question = " ".join(str(question or "").casefold().split())
|
||||
normalized_tool = str(tool_name or "").strip()
|
||||
selected: list[Mapping[str, Any]] = []
|
||||
for contract in load_query_contracts():
|
||||
tools = contract.get("applies_to_tools")
|
||||
if isinstance(tools, list) and normalized_tool not in {
|
||||
str(item) for item in tools
|
||||
}:
|
||||
continue
|
||||
intent = contract.get("intent_match")
|
||||
if not isinstance(intent, Mapping):
|
||||
continue
|
||||
subject_terms = [
|
||||
str(item).casefold()
|
||||
for item in intent.get("subject_terms_any", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
action_terms = [
|
||||
str(item).casefold()
|
||||
for item in intent.get("action_terms_any", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if subject_terms and not any(
|
||||
term in normalized_question for term in subject_terms
|
||||
):
|
||||
continue
|
||||
if action_terms and not any(
|
||||
term in normalized_question for term in action_terms
|
||||
):
|
||||
continue
|
||||
selected.append(contract)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def query_contract_guidance(question: str, tool_name: str) -> str:
|
||||
contracts = matching_query_contracts(question, tool_name)
|
||||
if not contracts:
|
||||
return ""
|
||||
return json.dumps(
|
||||
{"query_contracts": contracts},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def append_query_contract_guidance(
|
||||
tool_query: str,
|
||||
*,
|
||||
original_question: str,
|
||||
tool_name: str,
|
||||
) -> str:
|
||||
guidance = query_contract_guidance(original_question, tool_name)
|
||||
normalized = str(tool_query or "").strip()
|
||||
if not guidance:
|
||||
return normalized
|
||||
return (
|
||||
f"{normalized}\n"
|
||||
"다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. "
|
||||
f"계약: {guidance}"
|
||||
)
|
||||
|
||||
|
||||
def _json_data_result(value: str) -> Any:
|
||||
marker = "DATA_RESULT"
|
||||
position = value.find(marker)
|
||||
if position < 0:
|
||||
return None
|
||||
remainder = value[position + len(marker) :]
|
||||
object_position = remainder.find("{")
|
||||
array_position = remainder.find("[")
|
||||
positions = [
|
||||
candidate
|
||||
for candidate in (object_position, array_position)
|
||||
if candidate >= 0
|
||||
]
|
||||
if not positions:
|
||||
return None
|
||||
candidate = remainder[min(positions) :]
|
||||
try:
|
||||
parsed, _ = json.JSONDecoder().raw_decode(candidate)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _evidence_rows(value: Any) -> list[Mapping[str, Any]]:
|
||||
rows: list[Mapping[str, Any]] = []
|
||||
if isinstance(value, Mapping):
|
||||
rows.append(value)
|
||||
for item in value.values():
|
||||
rows.extend(_evidence_rows(item))
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
rows.extend(_evidence_rows(item))
|
||||
elif isinstance(value, str):
|
||||
parsed = _json_data_result(value)
|
||||
if parsed is not None:
|
||||
rows.extend(_evidence_rows(parsed))
|
||||
return rows
|
||||
|
||||
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
if isinstance(value, bool) or value is None:
|
||||
raise InvalidOperation
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _evaluate_contract_expression(
|
||||
expression: str,
|
||||
row: Mapping[str, Any],
|
||||
) -> Decimal:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
|
||||
def evaluate(node: ast.AST) -> Decimal:
|
||||
if isinstance(node, ast.Expression):
|
||||
return evaluate(node.body)
|
||||
if isinstance(node, ast.Name):
|
||||
key = node.id.casefold()
|
||||
if key not in row:
|
||||
raise InvalidOperation
|
||||
return _decimal(row[key])
|
||||
if isinstance(node, ast.Constant):
|
||||
return _decimal(node.value)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(
|
||||
node.op, (ast.UAdd, ast.USub)
|
||||
):
|
||||
value = evaluate(node.operand)
|
||||
return value if isinstance(node.op, ast.UAdd) else -value
|
||||
if isinstance(node, ast.BinOp) and isinstance(
|
||||
node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)
|
||||
):
|
||||
left = evaluate(node.left)
|
||||
right = evaluate(node.right)
|
||||
if isinstance(node.op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(node.op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(node.op, ast.Mult):
|
||||
return left * right
|
||||
return left / right
|
||||
raise ValueError("unsupported contract expression")
|
||||
|
||||
return evaluate(tree)
|
||||
|
||||
|
||||
def evidence_contract_report(
|
||||
contracts: tuple[Mapping[str, Any], ...],
|
||||
evidence: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = [
|
||||
{str(key).strip().casefold(): value for key, value in row.items()}
|
||||
for row in _evidence_rows(evidence)
|
||||
]
|
||||
reports: list[dict[str, Any]] = []
|
||||
for contract in contracts:
|
||||
data_contract = contract.get("data_contract")
|
||||
if not isinstance(data_contract, Mapping):
|
||||
continue
|
||||
required = [
|
||||
str(item).strip().casefold()
|
||||
for item in data_contract.get("required_fields", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
matching_row = next(
|
||||
(row for row in rows if required and all(key in row for key in required)),
|
||||
None,
|
||||
)
|
||||
observed = sorted(
|
||||
{
|
||||
key
|
||||
for row in rows
|
||||
for key in row
|
||||
if not required or key in required
|
||||
}
|
||||
)
|
||||
missing = [
|
||||
key.upper()
|
||||
for key in required
|
||||
if matching_row is None or key not in matching_row
|
||||
]
|
||||
computed_checks: list[dict[str, Any]] = []
|
||||
computed_fields = data_contract.get("computed_fields")
|
||||
if matching_row is not None and isinstance(computed_fields, Mapping):
|
||||
for field, definition in computed_fields.items():
|
||||
normalized_field = str(field).strip().casefold()
|
||||
expression = (
|
||||
str(definition.get("expression") or "").strip()
|
||||
if isinstance(definition, Mapping)
|
||||
else ""
|
||||
)
|
||||
if not normalized_field or not expression:
|
||||
continue
|
||||
try:
|
||||
actual = _decimal(matching_row.get(normalized_field))
|
||||
expected = _evaluate_contract_expression(
|
||||
expression,
|
||||
matching_row,
|
||||
)
|
||||
matches = actual == expected
|
||||
computed_checks.append(
|
||||
{
|
||||
"field": normalized_field.upper(),
|
||||
"expression": expression,
|
||||
"actual": str(actual),
|
||||
"expected": str(expected),
|
||||
"satisfied": matches,
|
||||
}
|
||||
)
|
||||
except (InvalidOperation, ValueError, ZeroDivisionError):
|
||||
computed_checks.append(
|
||||
{
|
||||
"field": normalized_field.upper(),
|
||||
"expression": expression,
|
||||
"satisfied": False,
|
||||
}
|
||||
)
|
||||
calculations_satisfied = all(
|
||||
bool(check.get("satisfied")) for check in computed_checks
|
||||
)
|
||||
temporal_checks: list[dict[str, Any]] = []
|
||||
temporal_contract = contract.get("temporal_contract")
|
||||
if matching_row is not None and isinstance(temporal_contract, Mapping):
|
||||
status_fields = [
|
||||
str(item).strip().casefold()
|
||||
for item in temporal_contract.get("period_status_fields", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
past_status = str(
|
||||
temporal_contract.get("past_period_status") or ""
|
||||
).strip()
|
||||
observed_status = next(
|
||||
(
|
||||
str(matching_row.get(field) or "").strip()
|
||||
for field in status_fields
|
||||
if str(matching_row.get(field) or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
if observed_status == past_status and past_status:
|
||||
decision_field = str(
|
||||
temporal_contract.get("past_period_decision_field") or ""
|
||||
).strip().casefold()
|
||||
decision_value = str(
|
||||
temporal_contract.get("past_period_decision_value") or ""
|
||||
).strip()
|
||||
actual_decision = str(
|
||||
matching_row.get(decision_field) or ""
|
||||
).strip()
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "past_period_decision",
|
||||
"field": decision_field.upper(),
|
||||
"expected": decision_value,
|
||||
"actual": actual_decision,
|
||||
"satisfied": bool(
|
||||
decision_field
|
||||
and decision_value
|
||||
and actual_decision == decision_value
|
||||
),
|
||||
}
|
||||
)
|
||||
forbidden_patterns = [
|
||||
str(item).strip()
|
||||
for item in temporal_contract.get(
|
||||
"forbidden_past_period_decision_field_patterns", []
|
||||
)
|
||||
if str(item).strip()
|
||||
]
|
||||
forbidden_fields = sorted(
|
||||
key.upper()
|
||||
for key in matching_row
|
||||
if any(
|
||||
re.search(pattern, key, flags=re.IGNORECASE)
|
||||
for pattern in forbidden_patterns
|
||||
)
|
||||
)
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "no_past_period_yes_no_decision",
|
||||
"forbidden_fields": forbidden_fields,
|
||||
"satisfied": not forbidden_fields,
|
||||
}
|
||||
)
|
||||
required_counts = [
|
||||
str(item).strip().casefold()
|
||||
for item in temporal_contract.get(
|
||||
"validate_stated_days_against", []
|
||||
)
|
||||
if str(item).strip()
|
||||
]
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "date_range_counts",
|
||||
"required_fields": [
|
||||
field.upper() for field in required_counts
|
||||
],
|
||||
"missing_fields": [
|
||||
field.upper()
|
||||
for field in required_counts
|
||||
if field not in matching_row
|
||||
],
|
||||
"satisfied": all(
|
||||
field in matching_row for field in required_counts
|
||||
),
|
||||
}
|
||||
)
|
||||
temporal_satisfied = all(
|
||||
bool(check.get("satisfied")) for check in temporal_checks
|
||||
)
|
||||
reports.append(
|
||||
{
|
||||
"contract_id": str(contract.get("id") or ""),
|
||||
"satisfied": bool(
|
||||
matching_row is not None
|
||||
and not missing
|
||||
and calculations_satisfied
|
||||
and temporal_satisfied
|
||||
),
|
||||
"required_fields": [key.upper() for key in required],
|
||||
"observed_required_fields": [key.upper() for key in observed],
|
||||
"missing_fields": missing,
|
||||
"computed_field_checks": computed_checks,
|
||||
"temporal_contract_checks": temporal_checks,
|
||||
"validated_record": (
|
||||
{
|
||||
key.upper(): matching_row.get(key)
|
||||
for key in required
|
||||
}
|
||||
if matching_row is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
return reports
|
||||
|
||||
|
||||
def missing_evidence_message(
|
||||
contracts: tuple[Mapping[str, Any], ...],
|
||||
) -> str:
|
||||
for contract in contracts:
|
||||
answer_contract = contract.get("answer_contract")
|
||||
if not isinstance(answer_contract, Mapping):
|
||||
continue
|
||||
message = str(answer_contract.get("missing_evidence_message") or "").strip()
|
||||
if message:
|
||||
return message
|
||||
return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다."
|
||||
|
||||
|
||||
__all__ = [
|
||||
"append_query_contract_guidance",
|
||||
"evidence_contract_report",
|
||||
"load_query_contracts",
|
||||
"matching_query_contracts",
|
||||
"missing_evidence_message",
|
||||
"query_contract_guidance",
|
||||
]
|
||||
264
ai-web-agent-console/ai_web_agent_console/questions.py
Normal file
264
ai-web-agent-console/ai_web_agent_console/questions.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
QUESTION_CATEGORIES = ("STRUCTURED", "RAG", "HYBRID")
|
||||
GENERIC_RAG_QUESTION_ID = "R0"
|
||||
MAX_QUESTION_CHARS = 2_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoQuestion:
|
||||
"""ID와 분류가 고정된 데모 질문."""
|
||||
|
||||
question_id: str
|
||||
category: str
|
||||
text: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.category not in QUESTION_CATEGORIES:
|
||||
raise ValueError("unsupported demo question category")
|
||||
if not self.question_id or not self.text.strip():
|
||||
raise ValueError("demo question id/text is required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedQuestionIntent:
|
||||
"""현재 질문만으로 결정된 실행 intent.
|
||||
|
||||
``question_id``는 local route/fixture를 설명하는 분류 label이다. 8500 provider
|
||||
입력으로 전달되지 않으며 UI에서 선택한 scenario ID도 이 모델에 들어오지
|
||||
않는다.
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
category: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.category not in QUESTION_CATEGORIES:
|
||||
raise ValueError("unsupported resolved question category")
|
||||
if self.question_id not in {
|
||||
GENERIC_RAG_QUESTION_ID,
|
||||
"S1",
|
||||
"S2",
|
||||
"S3",
|
||||
"S4",
|
||||
"S5",
|
||||
"R1",
|
||||
"R2",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
}:
|
||||
raise ValueError("unsupported resolved question id")
|
||||
|
||||
|
||||
COMMON_DEMO_QUESTIONS = (
|
||||
DemoQuestion("S1", "STRUCTURED", "상품별 계약 건수를 보여줘."),
|
||||
DemoQuestion("S2", "STRUCTURED", "총 지급보험금이 가장 큰 상품은?"),
|
||||
DemoQuestion(
|
||||
"S3",
|
||||
"STRUCTURED",
|
||||
"숫자로 계산 가능한 평균 보장금액이 높은 상품 10개를 보여줘.",
|
||||
),
|
||||
DemoQuestion("S4", "STRUCTURED", "고객 등급별 평균 보험료를 보여줘."),
|
||||
DemoQuestion("S5", "STRUCTURED", "이해관계자 역할별 인원 수를 보여줘."),
|
||||
DemoQuestion("R1", "RAG", "자동차보험 약관의 면책 사항은?"),
|
||||
DemoQuestion("R2", "RAG", "보험금 청구 시 필요한 서류는?"),
|
||||
DemoQuestion(
|
||||
"H1",
|
||||
"HYBRID",
|
||||
"보험금이 가장 큰 상품의 주요 면책 조항을 알려줘.",
|
||||
),
|
||||
DemoQuestion(
|
||||
"H2",
|
||||
"HYBRID",
|
||||
"청구가 많은 상품군의 보장 제외 조건을 알려줘.",
|
||||
),
|
||||
DemoQuestion(
|
||||
"H3",
|
||||
"HYBRID",
|
||||
"고객 등급별 보험료 수준을 보고, 관련 약관상 유의해야 할 보장 제외 조건도 함께 알려줘.",
|
||||
),
|
||||
)
|
||||
|
||||
_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS}
|
||||
if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS):
|
||||
raise RuntimeError("duplicate AI Web Agent Console demo question id")
|
||||
|
||||
|
||||
def question_by_id(question_id: str) -> DemoQuestion:
|
||||
"""정규화된 ID로 질문을 찾되, 알 수 없는 ID는 거부한다."""
|
||||
|
||||
normalized = str(question_id).strip().upper()
|
||||
try:
|
||||
return _QUESTION_BY_ID[normalized]
|
||||
except KeyError:
|
||||
raise ValueError("unknown AI Web Agent Console demo question id") from None
|
||||
|
||||
|
||||
def normalize_scenario_id(value: object) -> str | None:
|
||||
"""선택적인 preset ID를 정규화하되 실행 routing에는 관여하지 않는다."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("scenario id must be a string")
|
||||
normalized = value.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
question_by_id(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_question(question: object) -> tuple[str, str]:
|
||||
if not isinstance(question, str):
|
||||
raise ValueError("question must be a non-empty string")
|
||||
if len(question) > MAX_QUESTION_CHARS:
|
||||
raise ValueError("question exceeds the supported length")
|
||||
normalized = re.sub(
|
||||
r"\s+", " ", unicodedata.normalize("NFKC", question).strip().lower()
|
||||
)
|
||||
if not normalized:
|
||||
raise ValueError("question must be a non-empty string")
|
||||
return normalized, normalized.replace(" ", "")
|
||||
|
||||
|
||||
def _contains_any(text: str, candidates: tuple[str, ...]) -> bool:
|
||||
return any(candidate in text for candidate in candidates)
|
||||
|
||||
|
||||
def resolve_question_intent(question: object) -> ResolvedQuestionIntent:
|
||||
"""현재 질문 텍스트만으로 S/R/H route intent를 결정한다.
|
||||
|
||||
명확한 structured/hybrid intent에 해당하지 않는 질문은 임의로 추측하지 않고
|
||||
``R0`` generic RAG 검색으로 보낸다. 이 함수는 scenario 또는
|
||||
question ID hint를 받지 않으므로 preset metadata가 실행을 바꿀 수 없다.
|
||||
"""
|
||||
|
||||
normalized, compact = _normalized_question(question)
|
||||
|
||||
# Canonical preset은 기존 10문항 동작을 byte-for-byte 보존한다.
|
||||
for item in COMMON_DEMO_QUESTIONS:
|
||||
candidate, _ = _normalized_question(item.text)
|
||||
if normalized == candidate:
|
||||
return ResolvedQuestionIntent(item.question_id, item.category)
|
||||
|
||||
has_product = _contains_any(compact, ("상품", "상품군"))
|
||||
has_exclusion = _contains_any(
|
||||
compact,
|
||||
("면책", "보장제외", "제외조건", "보상하지않", "약관상유의"),
|
||||
)
|
||||
has_top = _contains_any(
|
||||
compact, ("가장큰", "최대", "최고", "1위", "상위", "제일많", "높은")
|
||||
)
|
||||
|
||||
# 자유 질문에서 자동차보험 보유 규모와 타사 대비 강점을 함께 요구하면
|
||||
# generic structured 조회와 자유 evidence 검색을 로컬에서 합성하는 H4로
|
||||
# 보낸다. Label은 routing metadata일 뿐 provider query ID가 아니다.
|
||||
has_auto_insurance = "자동차보험" in compact
|
||||
has_count = _contains_any(
|
||||
compact,
|
||||
(
|
||||
"갯수",
|
||||
"개수",
|
||||
"건수",
|
||||
"계약수",
|
||||
"몇개",
|
||||
"몇건",
|
||||
"보유수",
|
||||
"상품수",
|
||||
),
|
||||
)
|
||||
has_competitor_comparison = _contains_any(
|
||||
compact, ("타사", "경쟁사", "다른회사", "타보험사")
|
||||
) and _contains_any(compact, ("강점", "장점", "차별", "우위", "비교"))
|
||||
if has_auto_insurance and has_count and has_competitor_comparison:
|
||||
return ResolvedQuestionIntent("H4", "HYBRID")
|
||||
|
||||
# Hybrid를 먼저 판별해 정형 키워드가 포함된 복합 질문이 S/R 단일 route로
|
||||
# 축소되지 않도록 한다.
|
||||
if (
|
||||
_contains_any(compact, ("고객등급", "등급별"))
|
||||
and "보험료" in compact
|
||||
and has_exclusion
|
||||
):
|
||||
return ResolvedQuestionIntent("H3", "HYBRID")
|
||||
if (
|
||||
"청구" in compact
|
||||
and _contains_any(compact, ("많은", "빈도", "건수", "상위"))
|
||||
and has_product
|
||||
and has_exclusion
|
||||
):
|
||||
return ResolvedQuestionIntent("H2", "HYBRID")
|
||||
if (
|
||||
has_product
|
||||
and _contains_any(compact, ("지급보험금", "보험금"))
|
||||
and has_top
|
||||
and has_exclusion
|
||||
):
|
||||
return ResolvedQuestionIntent("H1", "HYBRID")
|
||||
|
||||
if (
|
||||
has_product
|
||||
and "계약" in compact
|
||||
and _contains_any(compact, ("건수", "계약수", "몇건", "집계"))
|
||||
):
|
||||
return ResolvedQuestionIntent("S1", "STRUCTURED")
|
||||
if (
|
||||
has_product
|
||||
and _contains_any(compact, ("지급보험금", "보험금총액", "총보험금"))
|
||||
and has_top
|
||||
):
|
||||
return ResolvedQuestionIntent("S2", "STRUCTURED")
|
||||
if (
|
||||
has_product
|
||||
and _contains_any(compact, ("보장금액", "가입금액"))
|
||||
and "평균" in compact
|
||||
and has_top
|
||||
):
|
||||
return ResolvedQuestionIntent("S3", "STRUCTURED")
|
||||
if (
|
||||
_contains_any(compact, ("고객등급", "등급별"))
|
||||
and "보험료" in compact
|
||||
and "평균" in compact
|
||||
):
|
||||
return ResolvedQuestionIntent("S4", "STRUCTURED")
|
||||
if (
|
||||
"이해관계자" in compact
|
||||
and "역할" in compact
|
||||
and _contains_any(compact, ("인원", "사람수", "몇명", "명수", "수"))
|
||||
):
|
||||
return ResolvedQuestionIntent("S5", "STRUCTURED")
|
||||
|
||||
if (
|
||||
"자동차보험" in compact
|
||||
and _contains_any(compact, ("면책", "보상하지않", "보장제외", "제외사항"))
|
||||
):
|
||||
return ResolvedQuestionIntent("R1", "RAG")
|
||||
if (
|
||||
_contains_any(compact, ("보험금", "청구"))
|
||||
and _contains_any(compact, ("서류", "문서", "증빙", "제출자료"))
|
||||
):
|
||||
return ResolvedQuestionIntent("R2", "RAG")
|
||||
|
||||
return ResolvedQuestionIntent(GENERIC_RAG_QUESTION_ID, "RAG")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMMON_DEMO_QUESTIONS",
|
||||
"DemoQuestion",
|
||||
"GENERIC_RAG_QUESTION_ID",
|
||||
"MAX_QUESTION_CHARS",
|
||||
"QUESTION_CATEGORIES",
|
||||
"ResolvedQuestionIntent",
|
||||
"normalize_scenario_id",
|
||||
"question_by_id",
|
||||
"resolve_question_intent",
|
||||
]
|
||||
71
ai-web-agent-console/ai_web_agent_console/scenarios.py
Normal file
71
ai-web-agent-console/ai_web_agent_console/scenarios.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""File-backed demo scenarios used by the PoC4 Streamlit screen.
|
||||
|
||||
Scenario content is deliberately configuration, not executable routing policy.
|
||||
Changing this JSON changes only the menu shown to a demo user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class ScenarioConfigError(RuntimeError):
|
||||
"""A safe, user-facing error for invalid scenario configuration."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoScenario:
|
||||
scenario_id: str
|
||||
category: str
|
||||
title: str
|
||||
question: str
|
||||
|
||||
# Compatibility aliases keep the Streamlit rendering independent from the
|
||||
# storage field names and make a future scenario source interchangeable.
|
||||
@property
|
||||
def question_id(self) -> str:
|
||||
return self.scenario_id
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.question
|
||||
|
||||
|
||||
def load_demo_scenarios(path: Path) -> tuple[DemoScenario, ...]:
|
||||
"""Read enabled scenarios and reject malformed or duplicated entries."""
|
||||
|
||||
try:
|
||||
payload: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
raise ScenarioConfigError(f"질문 시나리오 설정을 읽지 못했습니다: {path}") from None
|
||||
|
||||
raw_scenarios = payload.get("scenarios") if isinstance(payload, Mapping) else None
|
||||
if not isinstance(raw_scenarios, list):
|
||||
raise ScenarioConfigError("질문 시나리오 설정에 scenarios 배열이 필요합니다.")
|
||||
|
||||
scenarios: list[DemoScenario] = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in raw_scenarios:
|
||||
if not isinstance(raw, Mapping) or raw.get("enabled", True) is not True:
|
||||
continue
|
||||
scenario_id = str(raw.get("id") or "").strip().upper()
|
||||
category = str(raw.get("category") or "일반").strip()
|
||||
title = str(raw.get("title") or "").strip()
|
||||
question = str(raw.get("question") or "").strip()
|
||||
if not scenario_id or not title or not question:
|
||||
raise ScenarioConfigError("각 질문 시나리오에는 id, title, question이 필요합니다.")
|
||||
if scenario_id in seen_ids:
|
||||
raise ScenarioConfigError(f"중복된 질문 시나리오 ID입니다: {scenario_id}")
|
||||
scenarios.append(
|
||||
DemoScenario(
|
||||
scenario_id=scenario_id,
|
||||
category=category,
|
||||
title=title,
|
||||
question=question,
|
||||
)
|
||||
)
|
||||
seen_ids.add(scenario_id)
|
||||
return tuple(scenarios)
|
||||
6522
ai-web-agent-console/app.py
Normal file
6522
ai-web-agent-console/app.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HMM | 팀 포트폴리오 선사 실적</title>
|
||||
<style>
|
||||
:root { --hmm:#0065ad; --hmm-deep:#004a84; --ink:#102c43; --copy:#3d5363; --muted:#71818c; --line:#0d78c4; --pale:#edf7fc; --canvas:#f4f5f6; --green:#16815d; --amber:#b66b00; --red:#c5413c; font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif; color:var(--ink); }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-width:320px; background:var(--canvas); }
|
||||
.page { min-height:100vh; max-width:1480px; margin:0 auto; background:#fff; box-shadow:0 0 40px rgba(12,38,60,.08); }
|
||||
.wordmark { display:flex; align-items:flex-end; gap:9px; font-size:18px; font-weight:900; letter-spacing:-.03em; }
|
||||
.wordmark::before { content:""; width:24px; height:21px; display:inline-block; background:linear-gradient(155deg,transparent 37%,#0571bc 38% 47%,transparent 48%),linear-gradient(26deg,transparent 42%,#0571bc 43% 52%,transparent 53%); border-left:2px solid #0571bc; border-bottom:2px solid #0571bc; }
|
||||
.report-name { margin:24px 0 31px; font-weight:800; font-size:13px; line-height:1.3; }
|
||||
.report-name span { display:block; color:var(--hmm); }
|
||||
.side-tools { padding:12px 0 22px; border-bottom:1px solid #8f969a; color:#056bad; font-size:16px; letter-spacing:6px; }
|
||||
nav { margin-top:22px; } .nav-group { padding:15px 0; border-bottom:1px solid #8f969a; }
|
||||
.nav-group-title { margin:0 0 11px; font-size:12px; font-weight:850; text-transform:uppercase; }
|
||||
.nav-item { display:block; padding:5px 9px; color:#69747b; font-size:11px; line-height:1.3; text-decoration:none; }
|
||||
.nav-item.active { color:#fff; background:linear-gradient(90deg,#0066ae,#52a9d9); font-weight:800; }
|
||||
.side-foot { margin-top:28px; color:#748087; font-size:10px; line-height:1.6; }
|
||||
.content { padding:42px min(6vw,92px) 45px; }
|
||||
.brand-banner { display:flex; align-items:center; justify-content:space-between; gap:18px; padding-bottom:18px; }
|
||||
.brand-banner img { display:block; width:128px; height:auto; }
|
||||
.brand-banner span { color:#627887; font-size:10px; font-weight:750; letter-spacing:.12em; text-transform:uppercase; }
|
||||
.top-line { height:4px; background:#045fa5; margin-bottom:32px; }
|
||||
.report-head { display:flex; justify-content:space-between; align-items:flex-start; gap:28px; padding-bottom:35px; border-bottom:1px solid var(--line); }
|
||||
.kicker { margin:0 0 10px; color:#0070ba; font-size:12px; font-weight:850; letter-spacing:.025em; }
|
||||
h1 { margin:0; color:#075b9f; font-size:clamp(30px,3.25vw,50px); line-height:1.16; letter-spacing:-.07em; font-weight:700; }
|
||||
h1 b { font-weight:850; }
|
||||
.head-meta { min-width:195px; padding-left:22px; border-left:1px solid #96c8e7; color:#647682; font-size:11px; line-height:1.65; }
|
||||
.head-meta strong { display:block; color:#075b9f; font-size:12px; }
|
||||
.intro { padding:18px 0 23px; color:var(--copy); font-size:15px; line-height:1.75; border-bottom:1px solid #8ac5e8; }
|
||||
.section-heading { margin:0 0 16px; color:#005fa9; font-size:21px; letter-spacing:-.05em; }
|
||||
.section-heading small { margin-left:8px; color:#71818c; font-size:11px; letter-spacing:0; font-weight:500; }
|
||||
.executive { display:grid; grid-template-columns:1.45fr 1fr; gap:42px; padding:29px 0 30px; border-bottom:1px solid var(--line); }
|
||||
.metrics { display:grid; grid-template-columns:repeat(2,1fr); border-top:2px solid #1478bb; border-left:1px solid #c8dce9; }
|
||||
.metric { min-height:111px; padding:17px 18px; border-right:1px solid #c8dce9; border-bottom:1px solid #c8dce9; }
|
||||
.metric-label { color:#5b6f7e; font-size:11px; font-weight:700; }.metric-value { margin:8px 0 5px; color:#075b9f; font-size:27px; letter-spacing:-.055em; font-weight:850; font-variant-numeric:tabular-nums; }.metric-value.money { font-size:23px; }.metric-note { color:#75848c; font-size:10px; }
|
||||
.risk-panel { padding:2px 0 0 25px; border-left:1px dotted #1478bb; }.risk-panel h2 { margin:0 0 15px; color:#005fa9; font-size:20px; letter-spacing:-.05em; }
|
||||
.risk-message { margin:0 0 17px; color:var(--copy); font-size:12px; line-height:1.65; }.risk-list { display:grid; gap:9px; }.risk-item { display:flex; align-items:center; gap:10px; padding-bottom:8px; border-bottom:1px solid #d7e8f3; font-size:11px; }.risk-item:last-child { border-bottom:0; }.risk-item-name { flex:1; font-weight:750; }.pill { padding:3px 7px; border-radius:2px; font-size:10px; font-weight:850; }.pill.red { color:var(--red); background:#fff1f0; }.pill.amber { color:var(--amber); background:#fff6e7; }.pill.green { color:var(--green); background:#eaf7f0; }
|
||||
.portfolio { padding:31px 0 28px; border-bottom:1px solid var(--line); }.portfolio-grid { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(260px,.8fr); gap:43px; }.chart-note { margin:-8px 0 22px; color:#73838e; font-size:11px; }.bar-chart { display:grid; gap:12px; }.bar-row { display:grid; grid-template-columns:146px minmax(100px,1fr) 112px; align-items:center; gap:12px; }.bar-person { font-size:11px; }.bar-person b { display:block; color:#134f7f; font-size:12px; }.bar-person span { color:#788791; }.bar-lane { height:24px; background:#ebf2f6; overflow:hidden; }.bar { height:100%; min-width:4px; background:linear-gradient(90deg,#0073bb,#1aa2d8); position:relative; }.bar.critical { background:linear-gradient(90deg,#0073bb 0 84%,#d75349 84%); }.bar-value { color:#15486f; text-align:right; font-size:11px; font-weight:800; font-variant-numeric:tabular-nums; }.bar-value span { display:block; color:#72828c; font-size:9px; font-weight:500; }
|
||||
.chart-aside { padding:14px 0 0 22px; border-left:1px dotted #1478bb; }.chart-aside h3 { margin:0 0 14px; color:#005fa9; font-size:16px; }.legend { display:flex; justify-content:space-between; align-items:end; padding:10px 0; border-bottom:1px solid #d5e4ed; }.legend:last-child { border-bottom:0; }.legend-label { display:flex; align-items:center; gap:8px; font-size:11px; }.dot { width:10px; height:10px; border-radius:50%; }.dot.green { background:var(--green); }.dot.amber { background:var(--amber); }.dot.red { background:var(--red); }.legend strong { color:#075b9f; font-size:20px; }
|
||||
.detail { padding:31px 0 28px; }.detail-head { display:flex; justify-content:space-between; align-items:end; gap:16px; }.detail-head p { margin:0 0 16px; color:#75848c; font-size:11px; }.detail-table { width:100%; border-collapse:collapse; border-top:2px solid #1478bb; }.detail-table th { padding:11px 10px; background:#eff8fd; color:#176aa4; text-align:left; font-size:10px; font-weight:800; }.detail-table td { padding:12px 10px; border-bottom:1px solid #d4e3eb; color:#334f62; font-size:11px; }.detail-table .code { display:block; margin-bottom:2px; color:#75848c; font-size:9px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.detail-table .name { font-weight:750; }.number { text-align:right; font-variant-numeric:tabular-nums; }.negative { color:var(--red)!important; }.empty { padding:35px; color:#77868f; text-align:center; border:1px solid #d5e4ed; }
|
||||
.footnotes { display:grid; grid-template-columns:1.4fr 1fr; gap:25px; padding-top:19px; border-top:1px solid var(--line); color:#697d89; font-size:10px; line-height:1.7; }.footnotes h3 { margin:0 0 5px; color:#075b9f; font-size:11px; }.footnotes p,.footnotes ul { margin:0; padding-left:15px; }.footnotes p { padding-left:0; }
|
||||
@media(max-width:1000px){.content{padding:32px 35px 42px}.executive,.portfolio-grid{gap:25px}.bar-row{grid-template-columns:118px minmax(80px,1fr) 95px}}
|
||||
@media(max-width:760px){.content{padding:24px 18px 35px}.top-line{margin-bottom:20px}.report-head,.executive,.portfolio-grid,.footnotes{grid-template-columns:1fr;display:grid}.report-head{gap:17px;padding-bottom:24px}.head-meta{padding-left:0;border-left:0;border-top:1px solid #96c8e7;padding-top:10px}.intro{font-size:13px}.risk-panel,.chart-aside{padding:24px 0 0;border-left:0;border-top:1px dotted #1478bb}.bar-row{grid-template-columns:105px minmax(30px,1fr) 80px;gap:7px}.detail{overflow-x:auto}.detail-table{min-width:690px}.footnotes{gap:14px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="report-root" class="page" aria-live="polite"></main>
|
||||
<script>
|
||||
// 서버가 이미 권한 검사를 마친 조회 결과 JSON을 이 위치에 주입한다.
|
||||
const reportData = __REPORT_DATA__;
|
||||
const esc=(v)=>String(v??"").replace(/[&<>'"]/g,c=>({"&":"&","<":"<",">":">","'":"'","\"":"""}[c]));
|
||||
const n=(v,d=2)=>new Intl.NumberFormat("en-US",{maximumFractionDigits:d}).format(Number(v||0));
|
||||
const usd=(v)=>`${n(v)} USD`; const pct=(v)=>`${n(v)}%`; const when=(v)=>new Intl.DateTimeFormat("ko-KR",{dateStyle:"medium",timeStyle:"short",timeZone:"Asia/Seoul"}).format(new Date(v));
|
||||
const riskClass=(v)=>String(v||"").toLowerCase(); const riskLabel=(v)=>({GREEN:"정상",AMBER:"주의",RED:"위험"}[v]||v||"미분류");
|
||||
function aggregate(rows){ const risks={GREEN:0,AMBER:0,RED:0}; const people=new Map(); rows.forEach(row=>{const revenue=Number(row.latestRevenueUsd||0); const margin=Number(row.latestGrossMarginUsd||0); risks[row.latestRiskLevel]=(risks[row.latestRiskLevel]||0)+1; if(!people.has(row.employeeCode)) people.set(row.employeeCode,{code:row.employeeCode,name:row.employeeName,revenue:0,carriers:0,hasRisk:false}); const p=people.get(row.employeeCode);p.revenue+=revenue;p.carriers+=1;p.hasRisk||=row.latestRiskLevel==="RED"||row.latestRiskLevel==="AMBER";}); const totalRevenue=rows.reduce((s,r)=>s+Number(r.latestRevenueUsd||0),0); const totalMargin=rows.reduce((s,r)=>s+Number(r.latestGrossMarginUsd||0),0); return {risks,people:[...people.values()].sort((a,b)=>b.revenue-a.revenue),totalRevenue,totalMargin,avgReliability:rows.length?rows.reduce((s,r)=>s+Number(r.latestScheduleReliabilityPct||0),0)/rows.length:0}; }
|
||||
function badge(value){return `<span class="pill ${riskClass(value)}">${esc(riskLabel(value))}</span>`;}
|
||||
function detailRow(row){return `<tr><td><span class="code">${esc(row.employeeCode)}</span><span class="name">${esc(row.employeeName)}</span></td><td><span class="code">${esc(row.carrierCode)}</span><span class="name">${esc(row.carrierName)}</span></td><td class="number">${usd(row.latestRevenueUsd)}</td><td class="number ${Number(row.latestGrossMarginUsd)<0?"negative":""}">${usd(row.latestGrossMarginUsd)}</td><td class="number">${pct(row.latestScheduleReliabilityPct)}</td><td>${badge(row.latestRiskLevel)}</td></tr>`;}
|
||||
function renderHmmCarrierPerformanceReport(payload,target=document.getElementById("report-root")){const report=payload?.report||{};const rows=Array.isArray(payload?.rows)?payload.rows:[];const s=aggregate(rows);const max=Math.max(...s.people.map(p=>p.revenue),1);const watch=rows.filter(r=>r.latestRiskLevel!=="GREEN").sort((a,b)=>a.latestRiskLevel.localeCompare(b.latestRiskLevel));target.innerHTML=`
|
||||
<section class="content"><div class="brand-banner"><img src="https://eu-images.contentstack.com/v3/assets/bltdcfe6aab5515629e/bltaf50776e73f3149f/668ea7b97dc26754645e1830/hmmci.png?width=1400&auto=webp&quality=80&disable=upscale" alt="HMM"><span>HMM Management Report · Internal Demo</span></div><div class="top-line"></div><header class="report-head"><div><p class="kicker">${esc(report.id||"FEDERATION")} · ${esc(report.category||"HMM Business Intelligence")}</p><h1><b>${esc(report.title||"팀 포트폴리오")}</b> — 최신 선사 실적</h1></div><div class="head-meta"><strong>${esc(report.requestedBy||"-")} 팀장 조회</strong>생성 ${esc(when(report.generatedAt))}<br>MCP ${esc(report.execution?.calls||0)}회 호출<br>${esc(report.execution?.tool||"-")}</div></header>
|
||||
<p class="intro">${esc(report.answer||report.question||"조회된 선사 실적입니다.")} 전체 ${n(rows.length,0)}개 담당 선사의 매출·수익성·운항 지표를 담당자별 포트폴리오 관점에서 요약했습니다.</p>
|
||||
<section id="summary" class="executive"><div><h2 class="section-heading">핵심 요약 <small>Latest performance snapshot</small></h2><div class="metrics"><article class="metric"><div class="metric-label">담당 선사</div><div class="metric-value">${n(rows.length,0)}<small>개</small></div><div class="metric-note">팀원 ${n(s.people.length,0)}명 기준</div></article><article class="metric"><div class="metric-label">최신 매출 합계</div><div class="metric-value money">${usd(s.totalRevenue)}</div><div class="metric-note">담당 선사별 최신 기준월 합산</div></article><article class="metric"><div class="metric-label">매출총이익 합계</div><div class="metric-value money ${s.totalMargin<0?"negative":""}">${usd(s.totalMargin)}</div><div class="metric-note">음수 마진 선사 포함</div></article><article class="metric"><div class="metric-label">평균 정시 운항률</div><div class="metric-value">${pct(s.avgReliability)}</div><div class="metric-note">담당 선사 단순 평균</div></article></div></div><aside id="risk" class="risk-panel"><h2>위험 신호</h2><p class="risk-message">주의·위험 등급 선사 ${n(s.risks.AMBER+s.risks.RED,0)}개를 우선 점검 대상으로 표시합니다.</p><div class="risk-list">${watch.length?watch.map(r=>`<div class="risk-item">${badge(r.latestRiskLevel)}<span class="risk-item-name">${esc(r.carrierName)}</span><span>${esc(r.employeeName)}</span></div>`).join(""):'<div class="risk-item">현재 주의·위험 선사가 없습니다.</div>'}</div></aside></section>
|
||||
<section id="portfolio" class="portfolio"><h2 class="section-heading">담당자별 포트폴리오 매출 <small>Latest revenue by employee</small></h2><div class="portfolio-grid"><div><p class="chart-note">각 막대는 담당 선사 최신 매출의 합계입니다. 막대 끝의 색상은 해당 담당자 포트폴리오에 주의·위험 선사가 있는 경우를 나타냅니다.</p><div class="bar-chart">${s.people.map(p=>`<div class="bar-row"><div class="bar-person"><b>${esc(p.name)}</b><span>${esc(p.code)} · ${n(p.carriers,0)}개 선사</span></div><div class="bar-lane"><div class="bar ${p.hasRisk?"critical":""}" style="width:${(p.revenue/max*100).toFixed(2)}%"></div></div><div class="bar-value">${usd(p.revenue)}<span>팀 매출 ${(p.revenue/s.totalRevenue*100).toFixed(1)}%</span></div></div>`).join("")||'<div class="empty">차트 데이터가 없습니다.</div>'}</div></div><aside class="chart-aside"><h3>위험 등급 분포</h3><div class="legend"><span class="legend-label"><i class="dot green"></i>정상</span><strong>${n(s.risks.GREEN,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot amber"></i>주의</span><strong>${n(s.risks.AMBER,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot red"></i>위험</span><strong>${n(s.risks.RED,0)}</strong></div></aside></div></section>
|
||||
<section id="detail" class="detail"><div class="detail-head"><h2 class="section-heading">선사별 최신 지표 <small>Carrier detail</small></h2><p>총 ${n(rows.length,0)}건 · 최신순 1~${n(rows.length,0)}건 표시</p></div>${rows.length?`<table class="detail-table"><thead><tr><th>담당자</th><th>선사</th><th class="number">최신 매출</th><th class="number">매출총이익</th><th class="number">정시 운항률</th><th>위험 등급</th></tr></thead><tbody>${rows.map(detailRow).join("")}</tbody></table>`:'<div class="empty">표시할 상세 데이터가 없습니다.</div>'}</section>
|
||||
<footer id="notes" class="footnotes"><section><h3>답변 근거</h3><ul>${(report.evidence||[]).map(e=>`<li>${esc(e)}</li>`).join("")||'<li>제공된 근거가 없습니다.</li>'}</ul></section><section><h3>제약 및 주의</h3><p>${esc(report.limitation||"제약 정보가 제공되지 않았습니다.")}</p></section></footer></section>`;}
|
||||
window.renderHmmCarrierPerformanceReport=renderHmmCarrierPerformanceReport;renderHmmCarrierPerformanceReport(reportData);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
ai-web-agent-console/config/app_profile.json
Normal file
21
ai-web-agent-console/config/app_profile.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"product": {
|
||||
"name": "AI 업무 에이전트",
|
||||
"short_name": "AGENT",
|
||||
"page_title": "AI 업무 에이전트",
|
||||
"page_icon": "🤖",
|
||||
"header_title": "AI 업무 에이전트",
|
||||
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
|
||||
"login_kicker": "DATA & AI DEMO",
|
||||
"login_title": "AI 업무 에이전트",
|
||||
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
||||
"login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다."
|
||||
},
|
||||
"theme": {
|
||||
"primary_color": "#003b70",
|
||||
"text_color": "#172b3a",
|
||||
"muted_color": "#667785",
|
||||
"border_color": "#dfe7ed"
|
||||
}
|
||||
}
|
||||
76
ai-web-agent-console/config/hmm_demo_scenarios.json
Normal file
76
ai-web-agent-console/config/hmm_demo_scenarios.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "HMM AI 업무 에이전트 화면에 표시할 데모 질문 목록입니다. 이 파일을 수정하면 재배포 후 질문 메뉴에 반영됩니다.",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "HR-01",
|
||||
"enabled": true,
|
||||
"category": "휴가 규정",
|
||||
"title": "연차 이월 기준",
|
||||
"question": "연차 휴가 이월 기준과 제한을 알려줘"
|
||||
},
|
||||
{
|
||||
"id": "HR-02",
|
||||
"enabled": true,
|
||||
"category": "휴가 규정",
|
||||
"title": "입사일 기준 연차",
|
||||
"question": "입사일 기준으로 연차가 언제 발생하는지 알려줘"
|
||||
},
|
||||
{
|
||||
"id": "HR-03",
|
||||
"enabled": true,
|
||||
"category": "근태 용어",
|
||||
"title": "휴가 명칭 표준화",
|
||||
"question": "반차와 반일 휴가의 표준 근태 용어를 알려줘"
|
||||
},
|
||||
{
|
||||
"id": "HR-04",
|
||||
"enabled": true,
|
||||
"category": "조직·인력",
|
||||
"title": "팀 구성 조회",
|
||||
"question": "팀별 인원과 매니저를 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "HR-05",
|
||||
"enabled": true,
|
||||
"category": "근태 현황",
|
||||
"title": "휴가 현황 조회",
|
||||
"question": "이번 달 팀원별 휴가 사용 현황을 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-01",
|
||||
"enabled": true,
|
||||
"category": "선사 실적",
|
||||
"title": "우리 팀 담당 선사 최신 실적",
|
||||
"question": "우리 팀원별 담당 선사와 각 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-02",
|
||||
"enabled": true,
|
||||
"category": "선사 실적",
|
||||
"title": "주의가 필요한 선사",
|
||||
"question": "우리 팀이 담당하는 선사 중 최신 위험 등급이 RED인 선사의 담당자, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-03",
|
||||
"enabled": true,
|
||||
"category": "선사 실적",
|
||||
"title": "내 담당 선사 월별 추이",
|
||||
"question": "내가 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-04",
|
||||
"enabled": true,
|
||||
"category": "선사 실적 리포트",
|
||||
"title": "우리 팀 선사 실적 리포트",
|
||||
"question": "우리 팀원별 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 HMM 리포트로 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-05",
|
||||
"enabled": true,
|
||||
"category": "선사 실적 리포트",
|
||||
"title": "내 담당 선사 실적 리포트",
|
||||
"question": "내 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 HMM 리포트로 보여줘"
|
||||
}
|
||||
]
|
||||
}
|
||||
84
ai-web-agent-console/config/hmm_hr_query_contracts.json
Normal file
84
ai-web-agent-console/config/hmm_hr_query_contracts.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"version": 1,
|
||||
"contracts": [
|
||||
{
|
||||
"id": "leave_balance_eligibility",
|
||||
"description": "연차·휴가 잔여와 신청 가능 여부를 원장 연도 및 구성값으로 검증한다.",
|
||||
"applies_to_tools": [
|
||||
"search_hr_data"
|
||||
],
|
||||
"intent_match": {
|
||||
"subject_terms_any": [
|
||||
"연차",
|
||||
"휴가"
|
||||
],
|
||||
"action_terms_any": [
|
||||
"잔여",
|
||||
"가능",
|
||||
"승인",
|
||||
"신청",
|
||||
"사용할 수"
|
||||
]
|
||||
},
|
||||
"data_contract": {
|
||||
"source_object": "HMM_LEAVE_BALANCES",
|
||||
"identity_fields": [
|
||||
"EMPLOYEE_CODE"
|
||||
],
|
||||
"required_fields": [
|
||||
"LEAVE_YEAR",
|
||||
"ANNUAL_ENTITLEMENT",
|
||||
"CARRYOVER_DAYS",
|
||||
"USED_DAYS",
|
||||
"PENDING_DAYS",
|
||||
"AVAILABLE_REMAINING_DAYS"
|
||||
],
|
||||
"computed_fields": {
|
||||
"AVAILABLE_REMAINING_DAYS": {
|
||||
"expression": "ANNUAL_ENTITLEMENT + CARRYOVER_DAYS - USED_DAYS - PENDING_DAYS",
|
||||
"authoritative_source": "DATABASE_VIRTUAL_COLUMN"
|
||||
}
|
||||
},
|
||||
"missing_record_semantics": "NO_BALANCE_DATA_FOR_YEAR",
|
||||
"forbidden_missing_record_fallbacks": [
|
||||
"ZERO",
|
||||
"NVL",
|
||||
"COALESCE",
|
||||
"OUTER_JOIN_DEFAULT",
|
||||
"LITERAL_VALUE"
|
||||
]
|
||||
},
|
||||
"temporal_contract": {
|
||||
"current_balance_year": "LATEST_AVAILABLE_LEAVE_YEAR",
|
||||
"requested_period_and_current_balance_are_separate": true,
|
||||
"period_status_fields": [
|
||||
"REQUEST_PERIOD_LABEL",
|
||||
"PERIOD_STATUS"
|
||||
],
|
||||
"past_period_status": "HISTORICAL_REQUEST_PERIOD",
|
||||
"past_period_decision": "DO_NOT_DECIDE_NEW_APPLICATION_FROM_CURRENT_BALANCE",
|
||||
"past_period_decision_field": "APPLICATION_DECISION_STATUS",
|
||||
"past_period_decision_value": "NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",
|
||||
"forbidden_past_period_decision_field_patterns": [
|
||||
"^CAN_",
|
||||
"^APPROVAL_(ALLOWED|DENIED)$"
|
||||
],
|
||||
"validate_stated_days_against": [
|
||||
"INCLUSIVE_CALENDAR_DAYS",
|
||||
"MONDAY_TO_FRIDAY_DAYS"
|
||||
],
|
||||
"day_count_mismatch_status": "DATE_COUNT_REQUIRES_VALIDATION"
|
||||
},
|
||||
"answer_contract": {
|
||||
"separate_data_facts_and_policy_requirements": true,
|
||||
"deny_conclusions_without_required_fields": [
|
||||
"ZERO_REMAINING_DAYS",
|
||||
"LEAVE_APPROVAL_ALLOWED",
|
||||
"LEAVE_APPROVAL_DENIED"
|
||||
],
|
||||
"missing_evidence_response": "STATE_INSUFFICIENT_EVIDENCE_WITHOUT_INVENTING_VALUES",
|
||||
"missing_evidence_message": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
ai-web-agent-console/config/mcp_servers.json
Normal file
24
ai-web-agent-console/config/mcp_servers.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"default_server_id": "hmm_hr_mcp",
|
||||
"servers": [
|
||||
{
|
||||
"id": "hmm_hr_mcp",
|
||||
"enabled": true,
|
||||
"provider": "hmm_compat_mcp",
|
||||
"transport": "http",
|
||||
"endpoint_url": "https://hmm-backoffice.cloud-handson.com/mcp",
|
||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS",
|
||||
"default_tool": "search_hr_data",
|
||||
"router_model_profile": "gpt54_mini_oci",
|
||||
"tool_allowlist": [
|
||||
"search_hr_data",
|
||||
"resolve_hr_term",
|
||||
"search_hr_policy",
|
||||
"search_carrier_performance",
|
||||
"render_hmm_carrier_report"
|
||||
],
|
||||
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
ai-web-agent-console/config/model_profiles.json
Normal file
88
ai-web-agent-console/config/model_profiles.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
||||
"default_model_profile": "gpt55_oci",
|
||||
"source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f",
|
||||
"profiles": [
|
||||
{
|
||||
"model_key": "gpt55_oci",
|
||||
"display_name": "GPT-5.5 (OCI GenAI)",
|
||||
"provider": "oci",
|
||||
"model_id": "openai.gpt-5.5",
|
||||
"answer_model_id_alias": "OPENAI_GPT_5_5_CHAT",
|
||||
"answer_model_region": "us-chicago-1",
|
||||
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2",
|
||||
"verification_status": "VERIFIED_WITH_WARNINGS",
|
||||
"default_for_console": true,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.",
|
||||
"display_order": 0
|
||||
},
|
||||
{
|
||||
"model_key": "gpt54_mini_oci",
|
||||
"display_name": "GPT-5.4 Mini (OCI GenAI)",
|
||||
"provider": "oci",
|
||||
"model_id": "openai.gpt-5.4-mini",
|
||||
"answer_model_id_alias": "OPENAI_GPT_5_4_MINI_CHAT",
|
||||
"answer_model_region": "us-chicago-1",
|
||||
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1",
|
||||
"verification_status": "PARTIAL_VERIFIED",
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"model_key": "grok43",
|
||||
"display_name": "Grok 4.3",
|
||||
"provider": "oci",
|
||||
"model_id": "xai.grok-4.3",
|
||||
"answer_model_id_alias": "XAI_GROK_4_3_CHAT",
|
||||
"answer_model_region": "us-chicago-1",
|
||||
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3",
|
||||
"verification_status": "VERIFIED",
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"model_key": "llama4_maverick",
|
||||
"display_name": "Llama 4 Maverick",
|
||||
"provider": "oci",
|
||||
"model_id": "meta.llama-4-maverick-17b-128e-instruct-fp8",
|
||||
"answer_model_id_alias": "META_LLAMA_4_MAVERICK_CHAT",
|
||||
"answer_model_region": "us-chicago-1",
|
||||
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3",
|
||||
"verification_status": "VERIFIED",
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"model_key": "llama33_70b",
|
||||
"display_name": "Llama 3.3 70B",
|
||||
"provider": "oci",
|
||||
"model_id": "meta.llama-3.3-70b-instruct",
|
||||
"answer_model_id_alias": "META_LLAMA_3_3_70B_CHAT",
|
||||
"answer_model_region": "us-chicago-1",
|
||||
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3",
|
||||
"verification_status": "VERIFIED",
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||
"display_order": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
52
ai-web-agent-console/config/vpd_token_presets.json
Normal file
52
ai-web-agent-console/config/vpd_token_presets.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"version": 2,
|
||||
"description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.",
|
||||
"presets": [
|
||||
{
|
||||
"enabled": true,
|
||||
"default": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1001",
|
||||
"user_id": "E1001",
|
||||
"name": "Kim Minseo",
|
||||
"role": "HR Team Manager",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1002",
|
||||
"user_id": "E1002",
|
||||
"name": "Lee Jiwon",
|
||||
"role": "HR Operations Specialist",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1003",
|
||||
"user_id": "E1003",
|
||||
"name": "Park Dohyun",
|
||||
"role": "People Analytics Analyst",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1005",
|
||||
"user_id": "E1005",
|
||||
"name": "Han Seojun",
|
||||
"role": "Recruiting Specialist",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1007",
|
||||
"user_id": "E1007",
|
||||
"name": "Kang Minho",
|
||||
"role": "HR Coordinator",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
ai-web-agent-console/requirements-langgraph.txt
Normal file
4
ai-web-agent-console/requirements-langgraph.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
fastmcp==3.4.3
|
||||
langgraph>=1.2,<2
|
||||
oci>=2.180,<3
|
||||
5
ai-web-agent-console/requirements.txt
Normal file
5
ai-web-agent-console/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
openpyxl>=3.1,<4
|
||||
oci>=2.180,<3
|
||||
oracledb>=2,<4
|
||||
pandas>=2,<3
|
||||
streamlit>=1.35,<2
|
||||
217
ai-web-agent-console/tests/test_auth_gateway.py
Normal file
217
ai-web-agent-console/tests/test_auth_gateway.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from ai_web_agent_console.auth_gateway import ( # noqa: E402
|
||||
AuthConfig,
|
||||
LoginAttemptLimiter,
|
||||
SESSION_COOKIE_NAME,
|
||||
SessionTokenCodec,
|
||||
build_handler,
|
||||
clear_session_cookie_header,
|
||||
password_matches,
|
||||
session_cookie_header,
|
||||
)
|
||||
|
||||
|
||||
class AuthGatewayTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = "s" * 48
|
||||
self.codec = SessionTokenCodec(self.secret)
|
||||
|
||||
def test_session_token_round_trip_and_tamper_rejection(self) -> None:
|
||||
token = self.codec.issue("demo-admin", 3600, now=1_000)
|
||||
|
||||
session = self.codec.verify(token, "demo-admin", now=1_001)
|
||||
|
||||
self.assertIsNotNone(session)
|
||||
self.assertEqual("demo-admin", session.username)
|
||||
self.assertEqual(4_600, session.expires_at)
|
||||
self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001))
|
||||
self.assertIsNone(self.codec.verify(token, "other-user", now=1_001))
|
||||
|
||||
def test_expired_session_token_is_rejected(self) -> None:
|
||||
token = self.codec.issue("demo-admin", 300, now=1_000)
|
||||
|
||||
self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300))
|
||||
|
||||
def test_remember_cookie_has_required_security_attributes(self) -> None:
|
||||
header = session_cookie_header("signed-value", 604_800)
|
||||
|
||||
self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header)
|
||||
self.assertIn("Path=/", header)
|
||||
self.assertIn("Secure", header)
|
||||
self.assertIn("HttpOnly", header)
|
||||
self.assertIn("SameSite=Lax", header)
|
||||
self.assertIn("Max-Age=604800", header)
|
||||
self.assertNotIn("Domain=", header)
|
||||
|
||||
def test_session_cookie_omits_persistent_max_age(self) -> None:
|
||||
header = session_cookie_header("signed-value", None)
|
||||
|
||||
self.assertNotIn("Max-Age", header)
|
||||
self.assertIn("HttpOnly", header)
|
||||
|
||||
def test_logout_cookie_expires_immediately(self) -> None:
|
||||
header = clear_session_cookie_header()
|
||||
|
||||
self.assertIn("Max-Age=0", header)
|
||||
self.assertIn("Secure", header)
|
||||
self.assertIn("HttpOnly", header)
|
||||
|
||||
def test_pbkdf2_password_verification(self) -> None:
|
||||
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||
expected = hashlib.pbkdf2_hmac(
|
||||
"sha256", b"correct-password", salt, 200_000
|
||||
).hex()
|
||||
encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}"
|
||||
|
||||
self.assertTrue(password_matches("correct-password", encoded))
|
||||
self.assertFalse(password_matches("wrong-password", encoded))
|
||||
|
||||
def test_rate_limiter_blocks_only_after_threshold(self) -> None:
|
||||
limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10)
|
||||
|
||||
limiter.record_failure("client", now=1)
|
||||
self.assertFalse(limiter.blocked("client", now=2))
|
||||
limiter.record_failure("client", now=3)
|
||||
self.assertTrue(limiter.blocked("client", now=4))
|
||||
self.assertFalse(limiter.blocked("client", now=20))
|
||||
|
||||
def test_environment_config_requires_new_cookie_secret(self) -> None:
|
||||
previous = dict(os.environ)
|
||||
try:
|
||||
os.environ["POC4_LOGIN_USER"] = "demo-admin"
|
||||
os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded"
|
||||
os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None)
|
||||
with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"):
|
||||
AuthConfig.from_environment()
|
||||
finally:
|
||||
os.environ.clear()
|
||||
os.environ.update(previous)
|
||||
|
||||
def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None:
|
||||
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||
expected = hashlib.pbkdf2_hmac(
|
||||
"sha256", b"correct-password", salt, 200_000
|
||||
).hex()
|
||||
config = AuthConfig(
|
||||
username="demo-admin",
|
||||
password_pbkdf2=(
|
||||
f"pbkdf2_sha256$200000${salt.hex()}${expected}"
|
||||
),
|
||||
cookie_secret=self.secret,
|
||||
port=8621,
|
||||
)
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config))
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
connection = http.client.HTTPConnection(
|
||||
"127.0.0.1", server.server_address[1], timeout=3
|
||||
)
|
||||
try:
|
||||
connection.request("GET", "/auth/login")
|
||||
login_page = connection.getresponse()
|
||||
body = login_page.read().decode("utf-8")
|
||||
self.assertEqual(200, login_page.status)
|
||||
csrf_header = next(
|
||||
value
|
||||
for name, value in login_page.getheaders()
|
||||
if name.lower() == "set-cookie"
|
||||
and value.startswith("__Host-HMM_LOGIN_CSRF=")
|
||||
)
|
||||
csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0]
|
||||
self.assertIn(
|
||||
f'name="csrf" value="{csrf_value}"',
|
||||
body,
|
||||
)
|
||||
|
||||
payload = urlencode(
|
||||
{
|
||||
"csrf": csrf_value,
|
||||
"username": "demo-admin",
|
||||
"password": "correct-password",
|
||||
"remember": "yes",
|
||||
}
|
||||
)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/auth/login",
|
||||
body=payload,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}",
|
||||
},
|
||||
)
|
||||
logged_in = connection.getresponse()
|
||||
logged_in.read()
|
||||
self.assertEqual(303, logged_in.status)
|
||||
self.assertEqual("/", logged_in.getheader("Location"))
|
||||
self.assertNotRegex(logged_in.getheader("Location"), r"token|remember")
|
||||
session_header = next(
|
||||
value
|
||||
for name, value in logged_in.getheaders()
|
||||
if name.lower() == "set-cookie"
|
||||
and value.startswith(f"{SESSION_COOKIE_NAME}=")
|
||||
)
|
||||
session_value = session_header.split("=", 1)[1].split(";", 1)[0]
|
||||
self.assertIn("Secure", session_header)
|
||||
self.assertIn("HttpOnly", session_header)
|
||||
self.assertIn("SameSite=Lax", session_header)
|
||||
|
||||
connection.request(
|
||||
"GET",
|
||||
"/auth/check",
|
||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
|
||||
)
|
||||
check = connection.getresponse()
|
||||
check.read()
|
||||
self.assertEqual(204, check.status)
|
||||
self.assertEqual("demo-admin", check.getheader("X-Auth-User"))
|
||||
|
||||
connection.request(
|
||||
"GET",
|
||||
"/auth/check",
|
||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"},
|
||||
)
|
||||
tampered = connection.getresponse()
|
||||
tampered.read()
|
||||
self.assertEqual(401, tampered.status)
|
||||
|
||||
connection.request(
|
||||
"GET",
|
||||
"/auth/logout",
|
||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
|
||||
)
|
||||
logout = connection.getresponse()
|
||||
logout.read()
|
||||
self.assertEqual(303, logout.status)
|
||||
self.assertEqual("/auth/login", logout.getheader("Location"))
|
||||
self.assertTrue(
|
||||
any(
|
||||
name.lower() == "set-cookie" and "Max-Age=0" in value
|
||||
for name, value in logout.getheaders()
|
||||
)
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
181
ai-web-agent-console/tests/test_query_contracts.py
Normal file
181
ai-web-agent-console/tests/test_query_contracts.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import unittest
|
||||
|
||||
from ai_web_agent_console.query_contracts import (
|
||||
append_query_contract_guidance,
|
||||
evidence_contract_report,
|
||||
matching_query_contracts,
|
||||
)
|
||||
|
||||
|
||||
def test_leave_eligibility_question_selects_external_contract():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 잔여와 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
|
||||
assert [contract["id"] for contract in contracts] == [
|
||||
"leave_balance_eligibility"
|
||||
]
|
||||
|
||||
|
||||
def test_unrelated_tool_does_not_receive_leave_contract():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 잔여를 알려줘",
|
||||
"search_carrier_performance",
|
||||
)
|
||||
|
||||
assert contracts == ()
|
||||
|
||||
|
||||
def test_incomplete_result_cannot_be_interpreted_as_zero_balance():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 사용 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT: {"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"REMAINING_DAYS":0}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is False
|
||||
assert "AVAILABLE_REMAINING_DAYS" in report[0]["missing_fields"]
|
||||
assert "LEAVE_YEAR" in report[0]["missing_fields"]
|
||||
|
||||
|
||||
def test_complete_database_row_satisfies_contract_without_fixture_values():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":14}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is True
|
||||
assert report[0]["validated_record"]["LEAVE_YEAR"] == 2031
|
||||
assert report[0]["validated_record"]["AVAILABLE_REMAINING_DAYS"] == 14
|
||||
|
||||
|
||||
def test_conflicting_computed_value_fails_contract():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":0}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is False
|
||||
assert report[0]["computed_field_checks"][0]["expected"] == "14"
|
||||
assert report[0]["computed_field_checks"][0]["actual"] == "0"
|
||||
|
||||
|
||||
def test_historical_period_rejects_yes_no_decision_and_missing_counts():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":14,'
|
||||
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
|
||||
'"CAN_APPLY":"YES","INCLUSIVE_CALENDAR_DAYS":11}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is False
|
||||
failed = {
|
||||
check["check"]
|
||||
for check in report[0]["temporal_contract_checks"]
|
||||
if not check["satisfied"]
|
||||
}
|
||||
assert failed == {
|
||||
"past_period_decision",
|
||||
"no_past_period_yes_no_decision",
|
||||
"date_range_counts",
|
||||
}
|
||||
|
||||
|
||||
def test_historical_period_contract_accepts_non_decision_status():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":14,'
|
||||
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
|
||||
'"APPLICATION_DECISION_STATUS":'
|
||||
'"NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",'
|
||||
'"INCLUSIVE_CALENDAR_DAYS":11,'
|
||||
'"MONDAY_TO_FRIDAY_DAYS":7}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is True
|
||||
|
||||
|
||||
def test_guidance_is_appended_from_json_contract():
|
||||
guided = append_query_contract_guidance(
|
||||
"직원 E9999의 휴가 잔여를 조회해줘",
|
||||
original_question="직원 E9999의 휴가 잔여를 조회해줘",
|
||||
tool_name="search_hr_data",
|
||||
)
|
||||
|
||||
assert '"id":"leave_balance_eligibility"' in guided
|
||||
assert "AVAILABLE_REMAINING_DAYS" in guided
|
||||
|
||||
|
||||
class QueryContractTest(unittest.TestCase):
|
||||
test_leave_eligibility_question_selects_external_contract = staticmethod(
|
||||
test_leave_eligibility_question_selects_external_contract
|
||||
)
|
||||
test_unrelated_tool_does_not_receive_leave_contract = staticmethod(
|
||||
test_unrelated_tool_does_not_receive_leave_contract
|
||||
)
|
||||
test_incomplete_result_cannot_be_interpreted_as_zero_balance = staticmethod(
|
||||
test_incomplete_result_cannot_be_interpreted_as_zero_balance
|
||||
)
|
||||
test_complete_database_row_satisfies_contract_without_fixture_values = (
|
||||
staticmethod(test_complete_database_row_satisfies_contract_without_fixture_values)
|
||||
)
|
||||
test_conflicting_computed_value_fails_contract = staticmethod(
|
||||
test_conflicting_computed_value_fails_contract
|
||||
)
|
||||
test_historical_period_rejects_yes_no_decision_and_missing_counts = (
|
||||
staticmethod(test_historical_period_rejects_yes_no_decision_and_missing_counts)
|
||||
)
|
||||
test_historical_period_contract_accepts_non_decision_status = staticmethod(
|
||||
test_historical_period_contract_accepts_non_decision_status
|
||||
)
|
||||
test_guidance_is_appended_from_json_contract = staticmethod(
|
||||
test_guidance_is_appended_from_json_contract
|
||||
)
|
||||
538
ai-web-agent-console/tests/test_scenarios.py
Normal file
538
ai-web-agent-console/tests/test_scenarios.py
Normal file
@@ -0,0 +1,538 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Mapping
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios
|
||||
from ai_web_agent_console.profile import load_app_profile
|
||||
from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments
|
||||
from ai_web_agent_console.mcp_result import (
|
||||
has_actionable_text_result,
|
||||
status_result_evidence,
|
||||
status_result_summary,
|
||||
)
|
||||
from ai_web_agent_console.model_registry import load_model_registry
|
||||
|
||||
|
||||
def _load_console_query_helpers():
|
||||
"""Load the Streamlit entrypoint only when its optional runtime is installed."""
|
||||
|
||||
try:
|
||||
from app import _prepare_hmm_hr_tool_query
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
return _prepare_hmm_hr_tool_query
|
||||
|
||||
|
||||
class DemoScenarioConfigTest(unittest.TestCase):
|
||||
def test_model_registry_uses_console_names(self) -> None:
|
||||
registry = load_model_registry()
|
||||
|
||||
self.assertEqual(
|
||||
registry.registry_name,
|
||||
"AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
||||
)
|
||||
self.assertTrue(registry.default_profile.default_for_console)
|
||||
|
||||
def test_profile_environment_overrides_json_defaults(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"AGENT_CONSOLE_SHORT_NAME": "HMM",
|
||||
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
|
||||
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
profile = load_app_profile(path)
|
||||
|
||||
self.assertEqual(profile.short_name, "HMM")
|
||||
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
|
||||
self.assertEqual(profile.primary_color, "#003b70")
|
||||
|
||||
def test_profile_reads_dotenv_values(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env_file = Path(temp_dir) / ".env"
|
||||
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
|
||||
profile = load_app_profile(path, env_file)
|
||||
|
||||
self.assertEqual(profile.short_name, "HMM")
|
||||
|
||||
def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None:
|
||||
path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py"
|
||||
source = path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('[data-testid="stAppViewContainer"] li', source)
|
||||
self.assertIn('[data-testid="stExpander"] summary', source)
|
||||
self.assertIn('div[data-testid="stButton"] > button', source)
|
||||
self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source)
|
||||
self.assertIn('[data-testid="stTab"]', source)
|
||||
self.assertIn('[role="tab"][aria-selected="true"]', source)
|
||||
|
||||
def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
entrypoint = (root / "app.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
renderer = (root / "ai_web_agent_console" / "audit.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint)
|
||||
self.assertIn("_load_hmm_audit_inventory", entrypoint)
|
||||
self.assertIn("(protocol=tcps)(port=1521)", entrypoint)
|
||||
self.assertIn(
|
||||
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD",
|
||||
entrypoint,
|
||||
)
|
||||
self.assertIn("HMM 접근 관리", renderer)
|
||||
self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint)
|
||||
|
||||
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
||||
scenarios = load_demo_scenarios(path)
|
||||
|
||||
self.assertGreaterEqual(len(scenarios), 3)
|
||||
self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios}))
|
||||
self.assertTrue(all(item.question.strip() for item in scenarios))
|
||||
by_id = {item.scenario_id: item for item in scenarios}
|
||||
self.assertEqual(
|
||||
{"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"},
|
||||
{"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"} & set(by_id),
|
||||
)
|
||||
self.assertTrue(
|
||||
all("선사" in by_id[scenario_id].question for scenario_id in (
|
||||
"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"
|
||||
))
|
||||
)
|
||||
carrier_scenarios = [
|
||||
item for item in scenarios if item.scenario_id.startswith("FED-")
|
||||
]
|
||||
self.assertTrue(
|
||||
all(
|
||||
re.search(r"\bE\d{4,}\b", item.question, flags=re.IGNORECASE) is None
|
||||
for item in carrier_scenarios
|
||||
)
|
||||
)
|
||||
report_scenarios = [
|
||||
item for item in carrier_scenarios if "리포트로 보여줘" in item.question
|
||||
]
|
||||
self.assertGreaterEqual(len(report_scenarios), 2)
|
||||
self.assertTrue(
|
||||
all(item.category == "선사 실적 리포트" for item in report_scenarios)
|
||||
)
|
||||
|
||||
def test_hmm_mcp_allows_carrier_federation_tool(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "mcp_servers.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
server = next(
|
||||
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"search_carrier_performance",
|
||||
server["tool_allowlist"],
|
||||
)
|
||||
|
||||
def test_hmm_mcp_allows_dynamic_html_renderer(self) -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
payload = json.loads(
|
||||
(root / "config" / "mcp_servers.json").read_text(encoding="utf-8")
|
||||
)
|
||||
server = next(
|
||||
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
||||
)
|
||||
source = (root / "app.py").read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(
|
||||
server["endpoint_url"],
|
||||
"https://hmm-backoffice.cloud-handson.com/mcp",
|
||||
)
|
||||
self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
|
||||
self.assertNotIn('tool.name == "render_hmm_carrier_report"', source)
|
||||
|
||||
def test_hmm_report_rows_accept_nested_select_ai_json_array(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helper = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_mcp_structured_rows"
|
||||
)
|
||||
namespace: dict[str, Any] = {
|
||||
"Any": Any,
|
||||
"Mapping": Mapping,
|
||||
"json": json,
|
||||
}
|
||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
rows = namespace["_mcp_structured_rows"](
|
||||
{
|
||||
"response": {
|
||||
"result": json.dumps(
|
||||
[
|
||||
{"EMPLOYEE_CODE": "E9001", "CARRIER_CODE": "C901"},
|
||||
{"EMPLOYEE_CODE": "E9002", "CARRIER_CODE": "C902"},
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
|
||||
|
||||
def test_hmm_report_normalizes_repeated_select_ai_column_labels(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helpers = [
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name in {"_camel_case_key", "_normalize_presentation_value"}
|
||||
]
|
||||
namespace: dict[str, Any] = {
|
||||
"Any": Any,
|
||||
"Mapping": Mapping,
|
||||
"re": re,
|
||||
}
|
||||
exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
first = namespace["_normalize_presentation_value"](
|
||||
{"CARRIER_CODE": "C001", "LATEST_REVENUE_USD": 100}
|
||||
)
|
||||
repeated = namespace["_normalize_presentation_value"](
|
||||
{"carrier Code": "C002", "LATEST REVENUE USD": 200}
|
||||
)
|
||||
|
||||
self.assertEqual(first, {"carrierCode": "C001", "latestRevenueUsd": 100})
|
||||
self.assertEqual(repeated, {"carrierCode": "C002", "latestRevenueUsd": 200})
|
||||
|
||||
def test_hmm_report_title_and_answer_follow_presentation_contract(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helper = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_clean_presentation_title"
|
||||
)
|
||||
namespace: dict[str, Any] = {
|
||||
"Any": Any,
|
||||
"html": html,
|
||||
"re": re,
|
||||
}
|
||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
title = namespace["_clean_presentation_title"](
|
||||
"<b>E1001 팀 포트폴리오</b>"
|
||||
)
|
||||
|
||||
self.assertEqual(title, "E1001 팀 포트폴리오")
|
||||
self.assertIn('"title": _clean_presentation_title(title)', source)
|
||||
self.assertIn(
|
||||
'assistant_message["content"] = presentation_answer',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"presentation_answer = _presentation_completion_answer(",
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"do not reproduce HTML tags, Markdown tables",
|
||||
source,
|
||||
)
|
||||
|
||||
def test_chat_context_is_scoped_to_current_selected_user(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helper = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "load_chat_context"
|
||||
)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class FakeConnection:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params):
|
||||
captured["sql"] = sql
|
||||
captured["params"] = params
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return [{"question": "내 담당 선사", "answer": "2건"}]
|
||||
|
||||
namespace: dict[str, Any] = {
|
||||
"CHAT_CONTEXT_TURNS": 8,
|
||||
"MAX_CONVERSATION_MESSAGES": 16,
|
||||
"_chat_db_connect": FakeConnection,
|
||||
"_is_failed_synthesis_answer": lambda _value: False,
|
||||
}
|
||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
messages = namespace["load_chat_context"](
|
||||
"conversation-1",
|
||||
selected_user_id="E1002",
|
||||
)
|
||||
|
||||
self.assertIn("selected_user_id = ?", captured["sql"])
|
||||
self.assertEqual(
|
||||
captured["params"],
|
||||
("conversation-1", "E1002", "E1002", 8),
|
||||
)
|
||||
self.assertEqual(messages[0]["content"], "내 담당 선사")
|
||||
|
||||
def test_standalone_question_uses_current_user_without_prior_context(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helpers = [
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name in {"_conversation_context", "resolve_standalone_question"}
|
||||
]
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class FakeClient:
|
||||
def complete(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return json.dumps(
|
||||
{
|
||||
"standalone_question": (
|
||||
"E1002 사용자의 담당 선사 최신 실적을 조회해줘"
|
||||
)
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
namespace: dict[str, Any] = {
|
||||
"Any": Any,
|
||||
"Mapping": Mapping,
|
||||
"MAX_CONVERSATION_MESSAGES": 16,
|
||||
"json": json,
|
||||
"resolve_model_profile": lambda _key: SimpleNamespace(
|
||||
model_id="model",
|
||||
answer_model_region="region",
|
||||
answer_model_endpoint="endpoint",
|
||||
),
|
||||
"build_oci_genai_completion_client": lambda *_args: FakeClient(),
|
||||
"temperature_for_model_profile": lambda _profile: 0.0,
|
||||
}
|
||||
exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
rewritten = namespace["resolve_standalone_question"](
|
||||
question="내 담당 선사 최신 실적을 리포트로 보여줘",
|
||||
messages=[],
|
||||
model_profile_key="test",
|
||||
selected_user_id="E1002",
|
||||
)
|
||||
prompt_payload = json.loads(captured["user_prompt"])
|
||||
|
||||
self.assertTrue(rewritten.startswith("E1002"))
|
||||
self.assertEqual(prompt_payload["current_selected_user_id"], "E1002")
|
||||
self.assertIn("authoritative", captured["system_prompt"])
|
||||
|
||||
def test_report_payload_requester_prefers_current_selected_user(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helper = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_presentation_payload"
|
||||
)
|
||||
namespace: dict[str, Any] = {
|
||||
"Any": Any,
|
||||
"Mapping": Mapping,
|
||||
"datetime": __import__("datetime").datetime,
|
||||
"timezone": __import__("datetime").timezone,
|
||||
"re": re,
|
||||
"_mcp_structured_rows": lambda _value: [],
|
||||
"_normalize_presentation_value": lambda value: value,
|
||||
"_clean_presentation_title": lambda value: str(value),
|
||||
}
|
||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
payload = namespace["_presentation_payload"](
|
||||
"E1001 팀장 문맥이 남은 질문",
|
||||
[],
|
||||
title="담당 선사 실적",
|
||||
selected_user_id="E1002",
|
||||
)
|
||||
|
||||
self.assertEqual(payload["report"]["requestedBy"], "E1002")
|
||||
|
||||
def test_hmm_report_data_query_drops_html_format_request(self) -> None:
|
||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helper = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_fallback_presentation_data_query"
|
||||
)
|
||||
namespace: dict[str, Any] = {"re": re}
|
||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||
|
||||
query = namespace["_fallback_presentation_data_query"](
|
||||
"E1001 팀장의 담당 선사 최신 매출, 매출총이익, 정시 운항률, "
|
||||
"위험 등급을 HTML로 보여줘"
|
||||
)
|
||||
|
||||
self.assertNotIn("HTML", query.upper())
|
||||
self.assertIn("E1001", query)
|
||||
self.assertIn("매출총이익", query)
|
||||
self.assertIn("정시 운항률", query)
|
||||
self.assertIn("위험 등급", query)
|
||||
self.assertIn("_mcp_rows_contain_presentation_markup(mcp_result)", source)
|
||||
|
||||
personal_query = namespace["_fallback_presentation_data_query"](
|
||||
"내 담당 선사와 최신 매출을 리포트로 보여줘"
|
||||
)
|
||||
self.assertEqual(personal_query, "내 담당 선사와 최신 매출을 보여줘")
|
||||
|
||||
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
|
||||
template = (
|
||||
Path(__file__).parents[1]
|
||||
/ "assets"
|
||||
/ "hmm-carrier-performance-report.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(template.count("__REPORT_DATA__"), 1)
|
||||
self.assertNotIn("Bluewave Maritime", template)
|
||||
self.assertNotIn("Southern Cross Marine", template)
|
||||
|
||||
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
presets = payload["presets"]
|
||||
|
||||
self.assertEqual(payload["version"], 2)
|
||||
self.assertEqual({item["user_id"] for item in presets}, {
|
||||
"E1001", "E1002", "E1003", "E1005", "E1007"
|
||||
})
|
||||
self.assertEqual(
|
||||
{item["mcp_token_env"] for item in presets},
|
||||
{f"HMM_MCP_BEARER_TOKEN_{item['user_id']}" for item in presets},
|
||||
)
|
||||
self.assertTrue(all("token" not in item for item in presets))
|
||||
|
||||
def test_duplicate_id_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "scenarios.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"scenarios": [
|
||||
{"id": "HR-01", "title": "one", "question": "q1"},
|
||||
{"id": "HR-01", "title": "two", "question": "q2"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaises(ScenarioConfigError):
|
||||
load_demo_scenarios(path)
|
||||
|
||||
def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None:
|
||||
tool = McpTool(
|
||||
name="search_hr_data",
|
||||
description="",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
arguments = build_mcp_tool_arguments(
|
||||
tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data"
|
||||
)
|
||||
|
||||
self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"})
|
||||
|
||||
def test_term_tool_arguments_follow_discovered_term_schema(self) -> None:
|
||||
tool = McpTool(
|
||||
name="resolve_hr_term",
|
||||
description="",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {"term": {"type": "string"}},
|
||||
"required": ["term"],
|
||||
},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
arguments = build_mcp_tool_arguments(
|
||||
tool, "반차", 50, preferred_tool="search_hr_data"
|
||||
)
|
||||
|
||||
self.assertEqual(arguments, {"term": "반차"})
|
||||
|
||||
def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None:
|
||||
result = {
|
||||
"status": "success",
|
||||
"result": (
|
||||
"HR_POLICY_SEARCH_RESULT\n"
|
||||
"EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준"
|
||||
),
|
||||
}
|
||||
|
||||
summary = status_result_summary(result, excerpt_chars=40)
|
||||
evidence = status_result_evidence(result)
|
||||
|
||||
self.assertEqual(summary["status"], "success")
|
||||
self.assertGreater(summary["result_chars"], 40)
|
||||
self.assertIn("KR_Leave_Policy.pdf", evidence["result"])
|
||||
self.assertTrue(has_actionable_text_result(result))
|
||||
|
||||
def test_no_data_text_is_not_actionable(self) -> None:
|
||||
self.assertFalse(
|
||||
has_actionable_text_result({"status": "success", "result": "No data found"})
|
||||
)
|
||||
|
||||
@unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional")
|
||||
def test_policy_query_does_not_include_demo_user_context(self) -> None:
|
||||
prepare = _load_console_query_helpers()
|
||||
assert prepare is not None
|
||||
tool = McpTool(
|
||||
name="search_hr_policy",
|
||||
description="Search policy documents",
|
||||
schema={"properties": {"query": {"type": "string"}}},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
query = prepare(
|
||||
question="연차 휴가 이월 기준과 제한을 알려줘",
|
||||
tool=tool,
|
||||
model_profile_key="gpt54_mini_oci",
|
||||
selected_user_id="E1001",
|
||||
selected_user_role="HR Team Manager",
|
||||
selected_user_team="HMM HR Demo Team",
|
||||
selected_user_scope="팀원 6명 관리",
|
||||
)
|
||||
|
||||
self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -80,7 +80,7 @@ COMMIT;
|
||||
-- COMMIT;
|
||||
--
|
||||
-- The policy function vpd_region_filter handles CSV → IN-list
|
||||
-- conversion automatically. See sql/adb/06_policy.sql.
|
||||
-- conversion automatically. See database/adb/06_policy.sql.
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
PROMPT === Seed complete ===
|
||||
94
database/adb/16_agent_ords_security_local_cleanup.sql
Normal file
94
database/adb/16_agent_ords_security_local_cleanup.sql
Normal 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;
|
||||
@@ -46,6 +46,7 @@ CREATE TABLE cb_app_user (
|
||||
user_name VARCHAR2(50) NOT NULL,
|
||||
employee_no VARCHAR2(20) NOT NULL,
|
||||
dept_code VARCHAR2(20) NOT NULL,
|
||||
stakeholder_user_id VARCHAR2(30),
|
||||
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
|
||||
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
|
||||
);
|
||||
@@ -75,7 +76,7 @@ CREATE TABLE cb_permission_rule (
|
||||
perm_id NUMBER NOT NULL REFERENCES cb_permission(perm_id),
|
||||
rule_column VARCHAR2(128),
|
||||
rule_type VARCHAR2(30) NOT NULL,
|
||||
rule_value VARCHAR2(100)
|
||||
rule_value VARCHAR2(1000 CHAR)
|
||||
);
|
||||
|
||||
CREATE TABLE cb_permission_column (
|
||||
@@ -87,6 +88,7 @@ CREATE TABLE cb_permission_column (
|
||||
CREATE TABLE cb_agent_bearer_key (
|
||||
key_id NUMBER PRIMARY KEY,
|
||||
user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id),
|
||||
stakeholder_user_id VARCHAR2(30),
|
||||
key_hash VARCHAR2(128) NOT NULL UNIQUE,
|
||||
key_prefix VARCHAR2(16),
|
||||
issued_at DATE DEFAULT SYSDATE NOT NULL,
|
||||
@@ -96,9 +98,9 @@ CREATE TABLE cb_agent_bearer_key (
|
||||
);
|
||||
|
||||
PROMPT === 3. Seeding permissions ===
|
||||
INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', 'N', 'Y');
|
||||
INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', 'N', 'Y');
|
||||
INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', 'Y', 'Y');
|
||||
INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', NULL, 'N', 'Y');
|
||||
INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', NULL, 'N', 'Y');
|
||||
INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', NULL, 'Y', 'Y');
|
||||
|
||||
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL');
|
||||
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL');
|
||||
@@ -112,9 +114,12 @@ INSERT INTO cb_permission VALUES (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'A
|
||||
INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
||||
INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
||||
|
||||
INSERT INTO cb_permission_rule VALUES (1000, 100, 'MY_DEPT', NULL);
|
||||
INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL);
|
||||
INSERT INTO cb_permission_rule VALUES (3000, 300, 'ALL', NULL);
|
||||
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (1000, 100, NULL, 'MY_DEPT', NULL);
|
||||
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (2000, 200, NULL, 'SELF', NULL);
|
||||
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (3000, 300, NULL, 'ALL', NULL);
|
||||
|
||||
INSERT INTO cb_permission_column VALUES (300, 'CONTENTS');
|
||||
|
||||
115
database/adb/18_agent_ords_security_local_vpd_test.sql
Normal file
115
database/adb/18_agent_ords_security_local_vpd_test.sql
Normal 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;
|
||||
89
database/adb/19_agent_ords_security_local_dds_setup.sql
Normal file
89
database/adb/19_agent_ords_security_local_dds_setup.sql
Normal 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;
|
||||
46
database/adb/20_agent_ords_security_local_dds_test.sql
Normal file
46
database/adb/20_agent_ords_security_local_dds_test.sql
Normal 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;
|
||||
25
database/adb/21_agent_ords_security_ords_enable_schema.sql
Normal file
25
database/adb/21_agent_ords_security_ords_enable_schema.sql
Normal 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;
|
||||
@@ -19,7 +19,10 @@ SET FEEDBACK ON
|
||||
|
||||
PROMPT === Creating CB_ORDS handler package ===
|
||||
CREATE OR REPLACE PACKAGE cb_ords_handler_pkg AUTHID DEFINER AS
|
||||
PROCEDURE set_vpd_context(p_authorization IN VARCHAR2);
|
||||
PROCEDURE set_vpd_context(
|
||||
p_authorization IN VARCHAR2,
|
||||
p_probe_id IN VARCHAR2 DEFAULT NULL
|
||||
);
|
||||
FUNCTION set_vpd_context_sql(p_authorization IN VARCHAR2) RETURN NUMBER;
|
||||
PROCEDURE clear_vpd_context;
|
||||
END;
|
||||
@@ -38,12 +41,19 @@ CREATE OR REPLACE PACKAGE BODY cb_ords_handler_pkg AS
|
||||
RETURN TRIM(SUBSTR(v_auth, 8));
|
||||
END;
|
||||
|
||||
PROCEDURE set_vpd_context(p_authorization IN VARCHAR2) AS
|
||||
PROCEDURE set_vpd_context(
|
||||
p_authorization IN VARCHAR2,
|
||||
p_probe_id IN VARCHAR2 DEFAULT NULL
|
||||
) AS
|
||||
v_bearer_key VARCHAR2(4000);
|
||||
BEGIN
|
||||
DBMS_SESSION.CLEAR_IDENTIFIER;
|
||||
admin.cb_agent_ctx_pkg.clear_user;
|
||||
v_bearer_key := extract_bearer_key(p_authorization);
|
||||
admin.cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_key);
|
||||
IF TRIM(p_probe_id) IS NOT NULL THEN
|
||||
DBMS_SESSION.SET_IDENTIFIER(SUBSTR(TRIM(p_probe_id), 1, 64));
|
||||
END IF;
|
||||
END;
|
||||
|
||||
FUNCTION set_vpd_context_sql(p_authorization IN VARCHAR2) RETURN NUMBER AS
|
||||
@@ -55,6 +65,7 @@ CREATE OR REPLACE PACKAGE BODY cb_ords_handler_pkg AS
|
||||
PROCEDURE clear_vpd_context AS
|
||||
BEGIN
|
||||
admin.cb_agent_ctx_pkg.clear_user;
|
||||
DBMS_SESSION.CLEAR_IDENTIFIER;
|
||||
END;
|
||||
END;
|
||||
/
|
||||
@@ -93,7 +104,7 @@ DECLARE
|
||||
v_vpd_predicate VARCHAR2(32767);
|
||||
v_effective_sql VARCHAR2(32767);
|
||||
BEGIN
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header);
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
|
||||
|
||||
-- The predicate below is the value returned by the same DBMS_RLS policy
|
||||
-- function that protects ADMIN.CB_V_SEARCH_DOCUMENTS.
|
||||
@@ -156,6 +167,16 @@ END;
|
||||
p_param_type => 'STRING',
|
||||
p_access_method => 'IN'
|
||||
);
|
||||
ORDS.DEFINE_PARAMETER(
|
||||
p_module_name => 'cb.agent.security',
|
||||
p_pattern => 'vpd/documents',
|
||||
p_method => 'POST',
|
||||
p_name => 'X-VPD-Probe-Id',
|
||||
p_bind_variable_name => 'probe_id',
|
||||
p_source_type => 'HEADER',
|
||||
p_param_type => 'STRING',
|
||||
p_access_method => 'IN'
|
||||
);
|
||||
ORDS.DEFINE_PARAMETER(
|
||||
p_module_name => 'cb.agent.security',
|
||||
p_pattern => 'vpd/documents',
|
||||
44
database/adb/23_agent_ords_security_ords_handler_test.sql
Normal file
44
database/adb/23_agent_ords_security_ords_handler_test.sql
Normal 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;
|
||||
99
database/adb/24_agent_ords_security_inventory.sql
Normal file
99
database/adb/24_agent_ords_security_inventory.sql
Normal 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;
|
||||
@@ -9,6 +9,7 @@
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO ON
|
||||
SET FEEDBACK ON
|
||||
ALTER SESSION DISABLE PARALLEL DML;
|
||||
|
||||
PROMPT === Adding optional description column to bearer keys ===
|
||||
BEGIN
|
||||
@@ -120,14 +121,23 @@ EXCEPTION
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE TABLE cb_protected_object (
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE cb_protected_object (
|
||||
object_id NUMBER PRIMARY KEY,
|
||||
owner VARCHAR2(128) NOT NULL,
|
||||
object_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||
ords_path VARCHAR2(300) NOT NULL,
|
||||
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL,
|
||||
enabled_yn CHAR(1) DEFAULT ''Y'' CHECK (enabled_yn IN (''Y'',''N'')) NOT NULL,
|
||||
description VARCHAR2(200)
|
||||
);
|
||||
)';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))';
|
||||
@@ -139,16 +149,25 @@ EXCEPTION
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE TABLE cb_protected_column (
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE cb_protected_column (
|
||||
column_id NUMBER PRIMARY KEY,
|
||||
object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id),
|
||||
column_name VARCHAR2(128) NOT NULL,
|
||||
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
|
||||
sensitive_yn CHAR(1) DEFAULT ''N'' CHECK (sensitive_yn IN (''Y'',''N'')) NOT NULL,
|
||||
visible_role_id NUMBER,
|
||||
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL,
|
||||
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL,
|
||||
sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL,
|
||||
redaction_method VARCHAR2(20) DEFAULT ''NONE'' NOT NULL,
|
||||
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
|
||||
);
|
||||
)';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL)';
|
||||
@@ -177,13 +196,28 @@ WHERE sensitivity_level = 'PUBLIC'
|
||||
AND redaction_method = 'NONE'
|
||||
AND sensitive_yn = 'Y';
|
||||
|
||||
CREATE TABLE cb_permission_column (
|
||||
-- Autonomous Database can use parallel DML. Finish the compatibility update
|
||||
-- before merging the seed rows into the same table.
|
||||
COMMIT;
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE cb_permission_column (
|
||||
permission_id NUMBER NOT NULL,
|
||||
column_name VARCHAR2(128) NOT NULL,
|
||||
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
|
||||
);
|
||||
)';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE TABLE cb_ords_probe_audit (
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE cb_ords_probe_audit (
|
||||
audit_id NUMBER PRIMARY KEY,
|
||||
event_type VARCHAR2(50) NOT NULL,
|
||||
key_id NUMBER,
|
||||
@@ -193,7 +227,14 @@ CREATE TABLE cb_ords_probe_audit (
|
||||
error_code VARCHAR2(100),
|
||||
message VARCHAR2(500),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
|
||||
);
|
||||
)';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
379
database/adb/26_agent_ords_security_dynamic_vpd_filter.sql
Normal file
379
database/adb/26_agent_ords_security_dynamic_vpd_filter.sql
Normal file
@@ -0,0 +1,379 @@
|
||||
-- ============================================================
|
||||
-- 26_agent_ords_security_dynamic_vpd_filter.sql
|
||||
-- Replaces the demo VPD filter with a whitelist predicate builder.
|
||||
--
|
||||
-- The function does not hardcode object names or rule values. It reads:
|
||||
-- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE
|
||||
-- -> CB_PERMISSION -> CB_PERMISSION_RULE
|
||||
-- and builds a row predicate for the object passed by DBMS_RLS.
|
||||
-- A permission's rules are combined with AND. Separate ALLOW permissions are
|
||||
-- combined with OR and DENY permissions are subtracted at the end. This lets
|
||||
-- a token-context condition be narrowed by a literal static SQL condition.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === Creating dynamic whitelist VPD filter ===
|
||||
CREATE OR REPLACE FUNCTION cb_agent_doc_vpd_filter(
|
||||
p_schema IN VARCHAR2,
|
||||
p_object IN VARCHAR2
|
||||
) RETURN VARCHAR2
|
||||
AUTHID DEFINER
|
||||
AS
|
||||
-- This function is intentionally whitelist-first:
|
||||
-- no matched stored row rule means no rows are visible.
|
||||
v_user_id NUMBER;
|
||||
v_target VARCHAR2(128);
|
||||
v_allow_predicate VARCHAR2(32767);
|
||||
v_deny_predicate VARCHAR2(32767);
|
||||
v_active_permission_id NUMBER;
|
||||
v_active_permission_effect VARCHAR2(10);
|
||||
v_permission_predicate VARCHAR2(32767);
|
||||
v_permission_invalid BOOLEAN := FALSE;
|
||||
|
||||
FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
|
||||
BEGIN
|
||||
RETURN '''' || REPLACE(NVL(p_value, ''), '''', '''''') || '''';
|
||||
END;
|
||||
|
||||
FUNCTION safe_column(
|
||||
p_owner IN VARCHAR2,
|
||||
p_table_name IN VARCHAR2,
|
||||
p_column_name IN VARCHAR2
|
||||
) RETURN VARCHAR2 IS
|
||||
v_column VARCHAR2(128);
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
IF p_column_name IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
v_column := DBMS_ASSERT.SIMPLE_SQL_NAME(UPPER(TRIM(p_column_name)));
|
||||
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_tab_columns
|
||||
WHERE owner = UPPER(p_owner)
|
||||
AND table_name = UPPER(p_table_name)
|
||||
AND column_name = v_column;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
RETURN v_column;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
|
||||
FUNCTION safe_static_predicate(
|
||||
p_owner IN VARCHAR2,
|
||||
p_table_name IN VARCHAR2,
|
||||
p_predicate IN VARCHAR2
|
||||
) RETURN VARCHAR2 IS
|
||||
v_predicate VARCHAR2(32767) := TRIM(p_predicate);
|
||||
v_lexical VARCHAR2(32767);
|
||||
v_token VARCHAR2(128);
|
||||
v_index PLS_INTEGER := 1;
|
||||
v_count NUMBER;
|
||||
v_has_column BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- STATIC_SQL is intentionally an expression only: no statement
|
||||
-- separator/comment/bind/database-link/qualified object is accepted.
|
||||
-- Every identifier outside a string literal must be a column of the
|
||||
-- protected object or an allowlisted SQL operator/function.
|
||||
IF v_predicate IS NULL
|
||||
OR LENGTH(v_predicate) > 1000
|
||||
OR INSTR(v_predicate, ';') > 0
|
||||
OR INSTR(v_predicate, '--') > 0
|
||||
OR INSTR(v_predicate, '/*') > 0
|
||||
OR INSTR(v_predicate, '*/') > 0
|
||||
OR INSTR(v_predicate, ':') > 0
|
||||
OR INSTR(v_predicate, '@') > 0
|
||||
OR INSTR(v_predicate, '||') > 0
|
||||
OR REGEXP_LIKE(v_predicate, '[[:cntrl:]]')
|
||||
OR MOD(LENGTH(v_predicate) - LENGTH(REPLACE(v_predicate, '''', '')), 2) <> 0 THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- The unconditional predicate is a deliberately narrow exception to the
|
||||
-- column-reference rule below. It is used by the explicit KB VPD admin
|
||||
-- permission and is rendered by the backoffice as the physical condition
|
||||
-- "1 = 1", not as an implicit bypass.
|
||||
IF REGEXP_LIKE(v_predicate, '^1[[:space:]]*=[[:space:]]*1$') THEN
|
||||
RETURN '1 = 1';
|
||||
END IF;
|
||||
|
||||
-- Replace SQL string literals before extracting identifiers. Doubled
|
||||
-- quotes in a literal are supported by the expression below.
|
||||
v_lexical := REGEXP_REPLACE(v_predicate, '''(''''|[^''])*''', ' ');
|
||||
IF REGEXP_LIKE(v_lexical, '[[:alpha:]_$#][[:alnum:]_$#]*[[:space:]]*\.') THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
LOOP
|
||||
v_token := UPPER(REGEXP_SUBSTR(
|
||||
v_lexical, '[[:alpha:]_$#][[:alnum:]_$#]*', 1, v_index
|
||||
));
|
||||
EXIT WHEN v_token IS NULL;
|
||||
v_index := v_index + 1;
|
||||
|
||||
IF v_token IN (
|
||||
'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'LIKE', 'BETWEEN', 'ESCAPE',
|
||||
'TRUE', 'FALSE', 'UPPER', 'LOWER', 'TRIM', 'NVL', 'COALESCE',
|
||||
'TO_CHAR', 'TO_DATE', 'REGEXP_LIKE', 'INSTR', 'LENGTH', 'SUBSTR',
|
||||
'REPLACE', 'CAST', 'AS', 'DATE', 'TIMESTAMP', 'NUMBER', 'VARCHAR2', 'CHAR'
|
||||
) THEN
|
||||
NULL;
|
||||
ELSE
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_tab_columns
|
||||
WHERE owner = UPPER(p_owner)
|
||||
AND table_name = UPPER(p_table_name)
|
||||
AND column_name = v_token;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
v_has_column := TRUE;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN CASE WHEN v_has_column THEN v_predicate ELSE NULL END;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
|
||||
PROCEDURE append_permission(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
|
||||
BEGIN
|
||||
IF p_clause IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF p_effect = 'DENY' THEN
|
||||
IF v_deny_predicate IS NULL THEN
|
||||
v_deny_predicate := p_clause;
|
||||
ELSE
|
||||
v_deny_predicate := '(' || v_deny_predicate || ') OR (' || p_clause || ')';
|
||||
END IF;
|
||||
ELSIF v_allow_predicate IS NULL THEN
|
||||
v_allow_predicate := p_clause;
|
||||
ELSE
|
||||
v_allow_predicate := '(' || v_allow_predicate || ') OR (' || p_clause || ')';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
PROCEDURE append_rule(p_clause IN VARCHAR2) IS
|
||||
BEGIN
|
||||
IF p_clause IS NULL THEN
|
||||
v_permission_invalid := TRUE;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF v_permission_predicate IS NULL THEN
|
||||
v_permission_predicate := p_clause;
|
||||
ELSE
|
||||
v_permission_predicate := '(' || v_permission_predicate || ') AND (' || p_clause || ')';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
PROCEDURE flush_permission IS
|
||||
BEGIN
|
||||
IF NOT v_permission_invalid THEN
|
||||
append_permission(v_active_permission_effect, v_permission_predicate);
|
||||
END IF;
|
||||
v_permission_predicate := NULL;
|
||||
v_permission_invalid := FALSE;
|
||||
END;
|
||||
BEGIN
|
||||
BEGIN
|
||||
v_user_id := TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'));
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN '1 = 0';
|
||||
END;
|
||||
|
||||
IF v_user_id IS NULL OR p_schema IS NULL OR p_object IS NULL THEN
|
||||
RETURN '1 = 0';
|
||||
END IF;
|
||||
|
||||
v_target := UPPER(TRIM(p_object));
|
||||
|
||||
FOR r IN (
|
||||
WITH effective_role AS (
|
||||
SELECT ur.role_id
|
||||
FROM cb_user_role ur
|
||||
WHERE ur.user_id = v_user_id
|
||||
UNION
|
||||
SELECT gr.role_id
|
||||
FROM cb_user_group ug
|
||||
JOIN cb_app_group g
|
||||
ON g.group_id = ug.group_id
|
||||
AND g.active_yn = 'Y'
|
||||
JOIN cb_group_role gr
|
||||
ON gr.group_id = ug.group_id
|
||||
WHERE ug.user_id = v_user_id
|
||||
)
|
||||
SELECT p.perm_id,
|
||||
UPPER(TRIM(r.rule_type)) AS rule_type,
|
||||
UPPER(TRIM(r.rule_column)) AS rule_column,
|
||||
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
|
||||
r.rule_value
|
||||
FROM effective_role er
|
||||
JOIN cb_permission p
|
||||
ON p.role_id = er.role_id
|
||||
JOIN cb_permission_rule r
|
||||
ON r.perm_id = p.perm_id
|
||||
WHERE p.target_name = v_target
|
||||
AND p.action_name = 'SELECT'
|
||||
ORDER BY p.perm_id, r.rule_id
|
||||
) LOOP
|
||||
IF v_active_permission_id IS NULL THEN
|
||||
v_active_permission_id := r.perm_id;
|
||||
v_active_permission_effect := r.permission_effect;
|
||||
ELSIF v_active_permission_id <> r.perm_id THEN
|
||||
flush_permission;
|
||||
v_active_permission_id := r.perm_id;
|
||||
v_active_permission_effect := r.permission_effect;
|
||||
END IF;
|
||||
|
||||
DECLARE
|
||||
v_column VARCHAR2(128);
|
||||
v_static_predicate VARCHAR2(32767);
|
||||
BEGIN
|
||||
IF r.rule_type = 'ALL' THEN
|
||||
append_rule('1 = 1');
|
||||
ELSIF r.rule_type = 'MY_DEPT' THEN
|
||||
-- NULL rule_column keeps backward compatibility with the demo seed.
|
||||
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
|
||||
IF v_column IS NOT NULL THEN
|
||||
append_rule(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
|
||||
END IF;
|
||||
ELSIF r.rule_type = 'SELF' THEN
|
||||
-- NULL rule_column keeps backward compatibility with the demo seed.
|
||||
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
|
||||
IF v_column IS NOT NULL THEN
|
||||
append_rule(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
|
||||
END IF;
|
||||
ELSIF r.rule_type = 'DEPT' THEN
|
||||
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
|
||||
IF v_column IS NOT NULL THEN
|
||||
append_rule(v_column || ' = ' || quote_literal(r.rule_value));
|
||||
END IF;
|
||||
ELSIF r.rule_type = 'EMP_NO' THEN
|
||||
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
|
||||
IF v_column IS NOT NULL THEN
|
||||
append_rule(v_column || ' = ' || quote_literal(r.rule_value));
|
||||
END IF;
|
||||
ELSIF r.rule_type = 'TAG' THEN
|
||||
-- A TAG rule defaults to the ingestion metadata column TECH_TAG.
|
||||
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'TECH_TAG'));
|
||||
IF v_column IS NOT NULL
|
||||
AND r.rule_value IS NOT NULL
|
||||
AND REGEXP_LIKE(TRIM(r.rule_value), '^[A-Za-z0-9_-]+$') THEN
|
||||
append_rule(
|
||||
'REGEXP_LIKE(UPPER(' || v_column || '), ' ||
|
||||
quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')'
|
||||
);
|
||||
END IF;
|
||||
ELSIF r.rule_type IN (
|
||||
'TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
|
||||
) THEN
|
||||
-- 백오피스 권한 설정의 조건 코드를 안전한 WHERE 조각으로 확장한다.
|
||||
-- token -> stakeholder 해석은 context package가 한 번만 수행하며,
|
||||
-- VPD filter는 context와 대상/계약원장만 쓴다.
|
||||
v_column := safe_column(p_schema, p_object, r.rule_column);
|
||||
IF v_column IS NOT NULL
|
||||
AND UPPER(p_schema) = 'POC_2' THEN
|
||||
IF r.rule_type = 'TOKEN_SUBJECT'
|
||||
AND v_target = 'KB_STAKEHOLDERS'
|
||||
AND v_column = 'USER_ID' THEN
|
||||
append_rule(
|
||||
'USER_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
|
||||
);
|
||||
ELSIF r.rule_type = 'OWN_CONTRACT'
|
||||
AND v_target = 'KB_CONTRACTS'
|
||||
AND v_column = 'FC_ID' THEN
|
||||
append_rule(
|
||||
'FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
|
||||
);
|
||||
ELSIF r.rule_type = 'CHANNEL_CONTRACT'
|
||||
AND v_target = 'KB_CONTRACTS'
|
||||
AND v_column = 'FC_CHANNEL' THEN
|
||||
append_rule(
|
||||
'FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
|
||||
);
|
||||
ELSIF r.rule_type IN ('OWN_CUSTOMER', 'CHANNEL_CUSTOMER')
|
||||
AND v_target IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
|
||||
AND v_column IN ('CUST_ID', 'CONTRACT_NO') THEN
|
||||
DECLARE
|
||||
v_contract_key VARCHAR2(30);
|
||||
v_subject_filter VARCHAR2(500);
|
||||
BEGIN
|
||||
v_contract_key := CASE v_column
|
||||
WHEN 'CUST_ID' THEN 'CUST_ID'
|
||||
WHEN 'CONTRACT_NO' THEN 'CONTRACT_NO'
|
||||
END;
|
||||
v_subject_filter := CASE r.rule_type
|
||||
WHEN 'OWN_CUSTOMER' THEN
|
||||
'c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
|
||||
ELSE
|
||||
'c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
|
||||
END;
|
||||
append_rule(
|
||||
'EXISTS (SELECT 1 FROM (SELECT c.' || v_contract_key || ' AS access_key ' ||
|
||||
'FROM POC_2.KB_CONTRACTS c WHERE ' || v_subject_filter ||
|
||||
') allowed_contract WHERE allowed_contract.access_key = ' || v_column || ')'
|
||||
);
|
||||
END;
|
||||
END IF;
|
||||
END IF;
|
||||
ELSIF r.rule_type = 'STATIC_SQL' THEN
|
||||
v_static_predicate := safe_static_predicate(p_schema, p_object, r.rule_value);
|
||||
append_rule(v_static_predicate);
|
||||
ELSIF r.rule_type = '=' THEN
|
||||
v_column := safe_column(p_schema, p_object, r.rule_column);
|
||||
IF v_column IS NOT NULL THEN
|
||||
append_rule('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
|
||||
END IF;
|
||||
ELSIF r.rule_type IN ('!=', '<>') THEN
|
||||
v_column := safe_column(p_schema, p_object, r.rule_column);
|
||||
IF v_column IS NOT NULL THEN
|
||||
append_rule('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
|
||||
END IF;
|
||||
ELSE
|
||||
append_rule(NULL);
|
||||
END IF;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
flush_permission;
|
||||
|
||||
IF v_allow_predicate IS NULL THEN
|
||||
RETURN '1 = 0';
|
||||
END IF;
|
||||
|
||||
IF v_deny_predicate IS NULL THEN
|
||||
IF v_allow_predicate = '1 = 1' THEN
|
||||
RETURN '1 = 1';
|
||||
END IF;
|
||||
RETURN '(' || v_allow_predicate || ')';
|
||||
END IF;
|
||||
|
||||
RETURN '((' || v_allow_predicate || ') AND NOT (' || v_deny_predicate || '))';
|
||||
END;
|
||||
/
|
||||
|
||||
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
|
||||
|
||||
-- ORDS SQL tracing calls the same policy function after set_vpd_context so
|
||||
-- the response can expose the token-specific VPD predicate. This is
|
||||
-- diagnostic metadata only; the SELECT remains protected by DBMS_RLS.
|
||||
GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords;
|
||||
|
||||
PROMPT === Dynamic whitelist VPD filter ready ===
|
||||
@@ -3,7 +3,7 @@
|
||||
-- Unit-style checks for cb_agent_doc_vpd_filter.
|
||||
--
|
||||
-- Run after:
|
||||
-- @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||
-- @database/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
@@ -67,6 +67,12 @@ BEGIN
|
||||
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
|
||||
VALUES (456112, 'rule_test_deny_only', 'E456112', 'HR', 'N', 'Y');
|
||||
|
||||
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
|
||||
VALUES (456113, 'rule_test_static_and', 'E456113', 'HR', 'N', 'Y');
|
||||
|
||||
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
|
||||
VALUES (456114, 'rule_test_static_invalid', 'E456114', 'HR', 'N', 'Y');
|
||||
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456101, 'RULE_TEST_MY_DEPT');
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456102, 'RULE_TEST_SELF');
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456103, 'RULE_TEST_ALL');
|
||||
@@ -79,6 +85,8 @@ BEGIN
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456110, 'RULE_TEST_TARGET_MISMATCH');
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456111, 'RULE_TEST_ALLOW_DENY');
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456112, 'RULE_TEST_DENY_ONLY');
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456113, 'RULE_TEST_STATIC_AND');
|
||||
INSERT INTO cb_app_role(role_id, role_name) VALUES (456114, 'RULE_TEST_STATIC_INVALID');
|
||||
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456101, 456101);
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102);
|
||||
@@ -92,6 +100,8 @@ BEGIN
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456110, 456110);
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456111, 456111);
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456112, 456112);
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456113, 456113);
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (456114, 456114);
|
||||
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
|
||||
VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
|
||||
@@ -132,6 +142,12 @@ BEGIN
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
|
||||
VALUES (456113, 456112, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'DENY');
|
||||
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
|
||||
VALUES (456114, 456113, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
||||
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
|
||||
VALUES (456115, 456114, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
||||
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (456101, 456101, NULL, 'MY_DEPT', NULL);
|
||||
|
||||
@@ -171,6 +187,15 @@ BEGIN
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (456113, 456113, 'DEPT_CODE', '=', 'HR');
|
||||
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (456114, 456114, 'DEPT_CODE', '=', 'HR');
|
||||
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (456115, 456114, NULL, 'STATIC_SQL', 'OWNER_EMP_NO = ''E456113''');
|
||||
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (456116, 456115, NULL, 'STATIC_SQL', 'DEPT_CODE = ''HR''; 1 = 1');
|
||||
|
||||
COMMIT;
|
||||
END;
|
||||
/
|
||||
@@ -260,6 +285,8 @@ BEGIN
|
||||
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT', 456111, 'AND NOT');
|
||||
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT_DETAIL', 456111, 'TO_CHAR(DEPT_CODE) = ''HR''');
|
||||
assert_equals('DENY_ONLY_DENIES', 456112, '1 = 0');
|
||||
assert_contains('CONTEXT_AND_STATIC_SQL', 456113, 'TO_CHAR(DEPT_CODE) = ''HR'') AND (OWNER_EMP_NO = ''E456113'')');
|
||||
assert_equals('INVALID_STATIC_SQL_DENIES', 456114, '1 = 0');
|
||||
cb_agent_ctx_pkg.clear_user;
|
||||
END;
|
||||
/
|
||||
@@ -53,7 +53,7 @@ DECLARE
|
||||
v_vpd_predicate VARCHAR2(32767);
|
||||
v_effective_sql VARCHAR2(32767);
|
||||
BEGIN
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header);
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
|
||||
|
||||
BEGIN
|
||||
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS');
|
||||
@@ -163,6 +163,17 @@ END;
|
||||
p_access_method => 'IN'
|
||||
);
|
||||
|
||||
ORDS.DEFINE_PARAMETER(
|
||||
p_module_name => 'cb.agent.vector',
|
||||
p_pattern => 'search',
|
||||
p_method => 'POST',
|
||||
p_name => 'X-VPD-Probe-Id',
|
||||
p_bind_variable_name => 'probe_id',
|
||||
p_source_type => 'HEADER',
|
||||
p_param_type => 'STRING',
|
||||
p_access_method => 'IN'
|
||||
);
|
||||
|
||||
ORDS.DEFINE_PARAMETER(
|
||||
p_module_name => 'cb.agent.vector',
|
||||
p_pattern => 'search',
|
||||
28
database/adb/34_agent_ords_execution_evidence_grant.sql
Normal file
28
database/adb/34_agent_ords_execution_evidence_grant.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- ============================================================
|
||||
-- 34_agent_ords_execution_evidence_grant.sql
|
||||
-- Enable SQL_ID and DBMS_XPLAN evidence for VPD probe requests.
|
||||
--
|
||||
-- Run as SYS or a DBA account that can grant SYS.V_$ dynamic-performance
|
||||
-- views. Typical Autonomous ADMIN accounts cannot delegate these views.
|
||||
-- The grants are read-only and scoped to the BACKOFFICE_DB_USERNAME account
|
||||
-- that displays the evidence (for example, ADMIN). They do not grant table
|
||||
-- DML or alter DBMS_RLS enforcement.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO ON
|
||||
SET FEEDBACK ON
|
||||
|
||||
-- Change ADMIN when BACKOFFICE_DB_USERNAME uses a dedicated observer account.
|
||||
DEFINE diagnostic_user = ADMIN
|
||||
|
||||
PROMPT === Granting backoffice cursor-evidence read access ===
|
||||
GRANT SELECT ON V_$SQL TO &diagnostic_user;
|
||||
GRANT SELECT ON V_$SQL_PLAN TO &diagnostic_user;
|
||||
GRANT SELECT ON V_$SQL_PLAN_STATISTICS_ALL TO &diagnostic_user;
|
||||
GRANT SELECT ON V_$SESSION TO &diagnostic_user;
|
||||
GRANT EXECUTE ON DBMS_XPLAN TO &diagnostic_user;
|
||||
|
||||
PROMPT === Backoffice cursor-evidence grants ready ===
|
||||
PROMPT SQL_ID evidence will appear after the next /probe call.
|
||||
UNDEFINE diagnostic_user
|
||||
EXIT;
|
||||
24
database/adb/41_dds_row_column_fixture.sql
Normal file
24
database/adb/41_dds_row_column_fixture.sql
Normal 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.
|
||||
70
database/adb/42_agent_ords_fga_execution_audit.sql
Normal file
70
database/adb/42_agent_ords_fga_execution_audit.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- ============================================================
|
||||
-- 42_agent_ords_fga_execution_audit.sql
|
||||
-- Durable, request-correlated execution evidence for VPD probes.
|
||||
--
|
||||
-- Run as the protected-object owner (ADMIN in this PoC), after the
|
||||
-- VPD policies and CB_PROTECTED_OBJECT entries have been applied.
|
||||
--
|
||||
-- Each enabled protected object gets an FGA SELECT policy. Autonomous Oracle
|
||||
-- writes SQL_TEXT and RLS_INFO to UNIFIED_AUDIT_TRAIL (traditional databases
|
||||
-- can expose the same information through DBA_FGA_AUDIT_TRAIL). The ORDS handler puts the
|
||||
-- backoffice-generated X-VPD-Probe-Id into CLIENT_ID, so the UI can read
|
||||
-- exactly this request's audit row rather than guess from V$SQL.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO ON
|
||||
SET FEEDBACK ON
|
||||
|
||||
PROMPT === Applying FGA execution-audit policies ===
|
||||
DECLARE
|
||||
v_policy_name VARCHAR2(128);
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT object_id, owner, object_name
|
||||
FROM cb_protected_object
|
||||
WHERE enabled_yn = 'Y'
|
||||
ORDER BY object_id
|
||||
) LOOP
|
||||
IF UPPER(r.owner) <> USER THEN
|
||||
RAISE_APPLICATION_ERROR(
|
||||
-20842,
|
||||
'Run this script as protected object owner ' || r.owner
|
||||
|| ' (current user: ' || USER || ')'
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_policy_name := 'CB_VPD_EXEC_AUDIT_' || r.object_id;
|
||||
BEGIN
|
||||
DBMS_FGA.DROP_POLICY(
|
||||
object_schema => r.owner,
|
||||
object_name => r.object_name,
|
||||
policy_name => v_policy_name
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- ORA-28102: policy does not exist. Other errors must stop setup.
|
||||
IF SQLCODE <> -28102 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
|
||||
DBMS_FGA.ADD_POLICY(
|
||||
object_schema => r.owner,
|
||||
object_name => r.object_name,
|
||||
policy_name => v_policy_name,
|
||||
audit_condition => NULL,
|
||||
audit_column => NULL,
|
||||
statement_types => 'SELECT',
|
||||
audit_trail => DBMS_FGA.DB + DBMS_FGA.EXTENDED,
|
||||
enable => TRUE
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
/
|
||||
|
||||
COMMIT;
|
||||
|
||||
PROMPT === FGA policies are ready ===
|
||||
PROMPT Run 22, 29 and 43 as CB_ORDS, then run a new permission-result probe.
|
||||
PROMPT The resulting row is read from UNIFIED_AUDIT_TRAIL by CLIENT_IDENTIFIER.
|
||||
EXIT;
|
||||
32
database/adb/42_dds_fga_execution_evidence.sql
Normal file
32
database/adb/42_dds_fga_execution_evidence.sql
Normal 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;
|
||||
59
database/adb/43_agent_ords_probe_id_handler_patch.sql
Normal file
59
database/adb/43_agent_ords_probe_id_handler_patch.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- ============================================================
|
||||
-- 43_agent_ords_probe_id_handler_patch.sql
|
||||
-- Adds X-VPD-Probe-Id to existing generic cb.object.query handlers.
|
||||
--
|
||||
-- Run as CB_ORDS after 22_agent_ords_security_ords_handler_setup.sql.
|
||||
-- The dedicated document/vector handlers are updated by 22/29 themselves.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO ON
|
||||
SET FEEDBACK ON
|
||||
|
||||
PROMPT === Patching generic object-query handlers for FGA correlation ===
|
||||
DECLARE
|
||||
v_source CLOB;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT m.name AS module_name,
|
||||
t.uri_template,
|
||||
h.method,
|
||||
h.source
|
||||
FROM user_ords_handlers h
|
||||
JOIN user_ords_templates t ON t.id = h.template_id
|
||||
JOIN user_ords_modules m ON m.id = t.module_id
|
||||
WHERE m.name = 'cb.object.query'
|
||||
AND h.method = 'POST'
|
||||
AND INSTR(h.source, 'cb_ords_handler_pkg.set_vpd_context(:auth_header)') > 0
|
||||
) LOOP
|
||||
v_source := REPLACE(
|
||||
r.source,
|
||||
'cb_ords_handler_pkg.set_vpd_context(:auth_header)',
|
||||
'cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)'
|
||||
);
|
||||
|
||||
ORDS.DEFINE_HANDLER(
|
||||
p_module_name => r.module_name,
|
||||
p_pattern => r.uri_template,
|
||||
p_method => r.method,
|
||||
p_source_type => ORDS.source_type_plsql,
|
||||
p_source => v_source,
|
||||
p_items_per_page => 0
|
||||
);
|
||||
ORDS.DEFINE_PARAMETER(
|
||||
p_module_name => r.module_name,
|
||||
p_pattern => r.uri_template,
|
||||
p_method => r.method,
|
||||
p_name => 'X-VPD-Probe-Id',
|
||||
p_bind_variable_name => 'probe_id',
|
||||
p_source_type => 'HEADER',
|
||||
p_param_type => 'STRING',
|
||||
p_access_method => 'IN'
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
/
|
||||
|
||||
COMMIT;
|
||||
|
||||
PROMPT === Generic handler patch complete ===
|
||||
EXIT;
|
||||
244
database/adb/44_dds_mcp_local_end_user_setup.sql
Normal file
244
database/adb/44_dds_mcp_local_end_user_setup.sql
Normal file
@@ -0,0 +1,244 @@
|
||||
-- ============================================================
|
||||
-- 44_dds_mcp_local_end_user_setup.sql
|
||||
--
|
||||
-- Publishes every active CB_APP_USER as a passwordless local DDS END USER
|
||||
-- for the DDS SSE MCP path. The application executes one protected query
|
||||
-- per request with an EndUserSecurityContext; direct DB logon is not enabled.
|
||||
--
|
||||
-- Permission source: CB_APP_USER + direct/group role assignments +
|
||||
-- CB_PERMISSION / CB_PERMISSION_RULE / CB_PERMISSION_COLUMN.
|
||||
-- Current protected target: ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS.
|
||||
--
|
||||
-- Prerequisite: 25_agent_ords_security_backoffice_support.sql,
|
||||
-- 28_agent_ords_vector_tag_vpd_setup.sql,
|
||||
-- 32_dds_vector_tag_setup.sql.
|
||||
-- Run as ADMIN on Oracle AI Database 26ai.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === 1. Persisting the published local END USER mapping ===
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE q'[
|
||||
CREATE TABLE cb_dds_end_user_map (
|
||||
application_user_id NUMBER PRIMARY KEY REFERENCES cb_app_user(user_id),
|
||||
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||
lookup_key_ref VARCHAR2(128) NOT NULL,
|
||||
grant_name VARCHAR2(128) NOT NULL,
|
||||
publish_status VARCHAR2(20) NOT NULL
|
||||
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
|
||||
published_at TIMESTAMP,
|
||||
last_error VARCHAR2(1000)
|
||||
)]';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
MERGE INTO cb_dds_end_user_map target
|
||||
USING (
|
||||
SELECT user_id AS application_user_id,
|
||||
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
|
||||
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
|
||||
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
|
||||
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
|
||||
FROM cb_app_user
|
||||
) source
|
||||
ON (target.application_user_id = source.application_user_id)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
target.end_user_name = source.end_user_name,
|
||||
target.data_role_name = source.data_role_name,
|
||||
target.lookup_key_ref = source.lookup_key_ref,
|
||||
target.grant_name = source.grant_name
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
application_user_id, end_user_name, data_role_name, lookup_key_ref,
|
||||
grant_name, publish_status
|
||||
) VALUES (
|
||||
source.application_user_id, source.end_user_name, source.data_role_name,
|
||||
source.lookup_key_ref, source.grant_name, 'PENDING'
|
||||
);
|
||||
|
||||
PROMPT === 2. Publishing END USER, DATA ROLE, and configured vector grants ===
|
||||
DECLARE
|
||||
v_predicate VARCHAR2(32767);
|
||||
v_excluded_columns VARCHAR2(32767);
|
||||
v_select_clause VARCHAR2(32767);
|
||||
|
||||
PROCEDURE drop_data_grant(p_grant_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP DATA GRANT ADMIN.' || p_grant_name;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN NULL; -- First publish has no generated grant.
|
||||
END;
|
||||
|
||||
PROCEDURE revoke_data_role(p_role_name IN VARCHAR2, p_end_user_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'REVOKE DATA ROLE ' || p_role_name || ' FROM "' || p_end_user_name || '"';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN NULL; -- Revoke is intentionally idempotent in this publisher.
|
||||
END;
|
||||
|
||||
FUNCTION vector_predicate(p_user_id IN NUMBER) RETURN VARCHAR2 IS
|
||||
v_allow VARCHAR2(32767);
|
||||
v_deny VARCHAR2(32767);
|
||||
v_clause VARCHAR2(2000);
|
||||
v_effect VARCHAR2(10);
|
||||
v_type VARCHAR2(30);
|
||||
v_value VARCHAR2(100);
|
||||
|
||||
PROCEDURE append_clause(p_target IN OUT NOCOPY VARCHAR2, p_clause IN VARCHAR2) IS
|
||||
BEGIN
|
||||
IF p_clause IS NULL THEN RETURN; END IF;
|
||||
IF p_target IS NULL THEN p_target := '(' || p_clause || ')';
|
||||
ELSE p_target := p_target || ' OR (' || p_clause || ')'; END IF;
|
||||
END;
|
||||
BEGIN
|
||||
FOR rule IN (
|
||||
WITH effective_role AS (
|
||||
SELECT role_id FROM cb_user_role WHERE user_id = p_user_id
|
||||
UNION
|
||||
SELECT gr.role_id
|
||||
FROM cb_user_group ug
|
||||
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
|
||||
JOIN cb_group_role gr ON gr.group_id = ug.group_id
|
||||
WHERE ug.user_id = p_user_id
|
||||
)
|
||||
SELECT NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
|
||||
UPPER(TRIM(r.rule_type)) AS rule_type,
|
||||
TRIM(r.rule_value) AS rule_value
|
||||
FROM effective_role er
|
||||
JOIN cb_permission p ON p.role_id = er.role_id
|
||||
JOIN cb_permission_rule r ON r.perm_id = p.perm_id
|
||||
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
|
||||
AND p.action_name = 'SELECT'
|
||||
ORDER BY p.perm_id, r.rule_id
|
||||
) LOOP
|
||||
v_effect := rule.permission_effect;
|
||||
v_type := rule.rule_type;
|
||||
v_value := rule.rule_value;
|
||||
v_clause := NULL;
|
||||
IF v_type = 'ALL' THEN
|
||||
v_clause := '1 = 1';
|
||||
ELSIF v_type = 'TAG'
|
||||
AND v_value IS NOT NULL
|
||||
AND REGEXP_LIKE(v_value, '^[A-Za-z0-9_-]+$') THEN
|
||||
v_clause := 'REGEXP_LIKE(UPPER(tech_tag), ''(^|,)' || UPPER(v_value) || '(,|$)'')';
|
||||
END IF;
|
||||
IF v_effect = 'DENY' THEN append_clause(v_deny, v_clause);
|
||||
ELSE append_clause(v_allow, v_clause); END IF;
|
||||
END LOOP;
|
||||
IF v_allow IS NULL THEN RETURN NULL; END IF;
|
||||
IF v_deny IS NULL THEN RETURN v_allow; END IF;
|
||||
RETURN '(' || v_allow || ') AND NOT (' || v_deny || ')';
|
||||
END;
|
||||
|
||||
FUNCTION excluded_vector_columns(p_user_id IN NUMBER) RETURN VARCHAR2 IS
|
||||
v_columns VARCHAR2(32767);
|
||||
BEGIN
|
||||
SELECT LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name)
|
||||
INTO v_columns
|
||||
FROM (
|
||||
SELECT pc.column_name
|
||||
FROM cb_protected_column pc
|
||||
JOIN cb_protected_object po ON po.object_id = pc.object_id
|
||||
WHERE po.object_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
|
||||
AND pc.sensitive_yn = 'Y'
|
||||
-- VECTOR_DISTANCE needs EMBEDDING internally. The MCP response never
|
||||
-- returns it, but it must stay readable by the database engine.
|
||||
AND pc.column_name <> 'EMBEDDING'
|
||||
AND NOT EXISTS (
|
||||
WITH effective_role AS (
|
||||
SELECT role_id FROM cb_user_role WHERE user_id = p_user_id
|
||||
UNION
|
||||
SELECT gr.role_id
|
||||
FROM cb_user_group ug
|
||||
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
|
||||
JOIN cb_group_role gr ON gr.group_id = ug.group_id
|
||||
WHERE ug.user_id = p_user_id
|
||||
)
|
||||
SELECT 1
|
||||
FROM effective_role er
|
||||
JOIN cb_permission p ON p.role_id = er.role_id
|
||||
JOIN cb_permission_column allowed ON allowed.permission_id = p.perm_id
|
||||
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
|
||||
AND p.action_name = 'SELECT'
|
||||
AND p.permission_effect = 'ALLOW'
|
||||
AND allowed.column_name = pc.column_name
|
||||
)
|
||||
);
|
||||
RETURN v_columns;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN RETURN NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
FOR mapped_user IN (
|
||||
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name
|
||||
FROM cb_dds_end_user_map m
|
||||
JOIN cb_app_user u ON u.user_id = m.application_user_id
|
||||
WHERE u.active = 'Y'
|
||||
ORDER BY m.application_user_id
|
||||
) LOOP
|
||||
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
|
||||
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
|
||||
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|
||||
|| ' TO "' || mapped_user.end_user_name || '"';
|
||||
|
||||
v_predicate := vector_predicate(mapped_user.application_user_id);
|
||||
drop_data_grant(mapped_user.grant_name);
|
||||
IF v_predicate IS NOT NULL THEN
|
||||
v_excluded_columns := excluded_vector_columns(mapped_user.application_user_id);
|
||||
v_select_clause := CASE WHEN v_excluded_columns IS NULL THEN 'AS SELECT'
|
||||
ELSE 'AS SELECT (ALL COLUMNS EXCEPT ' || v_excluded_columns || ')' END;
|
||||
EXECUTE IMMEDIATE 'CREATE DATA GRANT ADMIN.' || mapped_user.grant_name || ' '
|
||||
|| v_select_clause || ' ON ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS WHERE '
|
||||
|| v_predicate || ' TO ' || mapped_user.data_role_name;
|
||||
END IF;
|
||||
UPDATE cb_dds_end_user_map
|
||||
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
|
||||
WHERE application_user_id = mapped_user.application_user_id;
|
||||
END LOOP;
|
||||
|
||||
-- A disabled application user must not retain a usable DDS role/grant.
|
||||
FOR inactive_user IN (
|
||||
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name
|
||||
FROM cb_dds_end_user_map m
|
||||
JOIN cb_app_user u ON u.user_id = m.application_user_id
|
||||
WHERE u.active <> 'Y'
|
||||
) LOOP
|
||||
drop_data_grant(inactive_user.grant_name);
|
||||
revoke_data_role(inactive_user.data_role_name, inactive_user.end_user_name);
|
||||
UPDATE cb_dds_end_user_map
|
||||
SET publish_status = 'REVOKED', published_at = SYSTIMESTAMP, last_error = NULL
|
||||
WHERE application_user_id = inactive_user.application_user_id;
|
||||
END LOOP;
|
||||
COMMIT;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 3. Published DDS MCP identity inventory ===
|
||||
SELECT m.application_user_id,
|
||||
u.user_name,
|
||||
m.end_user_name,
|
||||
m.data_role_name,
|
||||
m.grant_name,
|
||||
m.publish_status
|
||||
FROM cb_dds_end_user_map m
|
||||
JOIN cb_app_user u ON u.user_id = m.application_user_id
|
||||
ORDER BY m.application_user_id;
|
||||
|
||||
SELECT rg.grantee AS end_user_name,
|
||||
rg.data_role,
|
||||
dg.grant_name,
|
||||
dg.object_name
|
||||
FROM dba_data_role_grants rg
|
||||
LEFT JOIN dba_data_grants dg ON dg.grantee = rg.data_role
|
||||
WHERE rg.grantee LIKE 'DDS\_U\_%' ESCAPE '\'
|
||||
ORDER BY rg.grantee, dg.grant_name;
|
||||
|
||||
PROMPT === DDS MCP local END USER publish complete ===
|
||||
EXIT;
|
||||
59
database/adb/45_dds_mcp_local_end_user_test.sql
Normal file
59
database/adb/45_dds_mcp_local_end_user_test.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- ============================================================
|
||||
-- 45_dds_mcp_local_end_user_test.sql
|
||||
-- Catalog-level acceptance test for 44. Run as ADMIN.
|
||||
-- The SSE/JDBC context assertion is performed by dds_vector_search after
|
||||
-- the OCI IAM database-access token configuration is supplied.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET LINESIZE 220
|
||||
SET PAGESIZE 200
|
||||
|
||||
DECLARE
|
||||
v_missing_map NUMBER;
|
||||
v_missing_end_user NUMBER;
|
||||
v_missing_role NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_missing_map
|
||||
FROM cb_app_user u
|
||||
LEFT JOIN cb_dds_end_user_map m ON m.application_user_id = u.user_id
|
||||
WHERE u.active = 'Y'
|
||||
AND (m.application_user_id IS NULL OR m.publish_status <> 'PUBLISHED');
|
||||
|
||||
SELECT COUNT(*) INTO v_missing_end_user
|
||||
FROM cb_dds_end_user_map m
|
||||
JOIN cb_app_user u ON u.user_id = m.application_user_id AND u.active = 'Y'
|
||||
LEFT JOIN dba_end_users eu ON eu.username = m.end_user_name
|
||||
WHERE eu.username IS NULL;
|
||||
|
||||
SELECT COUNT(*) INTO v_missing_role
|
||||
FROM cb_dds_end_user_map m
|
||||
JOIN cb_app_user u ON u.user_id = m.application_user_id AND u.active = 'Y'
|
||||
LEFT JOIN dba_data_role_grants rg
|
||||
ON rg.grantee = m.end_user_name
|
||||
AND rg.data_role = m.data_role_name
|
||||
WHERE rg.data_role IS NULL;
|
||||
|
||||
IF v_missing_map <> 0 OR v_missing_end_user <> 0 OR v_missing_role <> 0 THEN
|
||||
RAISE_APPLICATION_ERROR(-20944,
|
||||
'DDS MCP publish validation failed: map=' || v_missing_map
|
||||
|| ', end_user=' || v_missing_end_user || ', data_role=' || v_missing_role);
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
COLUMN user_name FORMAT A22
|
||||
COLUMN end_user_name FORMAT A18
|
||||
COLUMN data_role_name FORMAT A24
|
||||
COLUMN grant_name FORMAT A34
|
||||
SELECT u.user_name, m.end_user_name, m.data_role_name, m.grant_name,
|
||||
CASE WHEN dg.grant_name IS NULL THEN 'DEFAULT_DENY' ELSE 'PUBLISHED' END AS vector_access
|
||||
FROM cb_dds_end_user_map m
|
||||
JOIN cb_app_user u ON u.user_id = m.application_user_id
|
||||
LEFT JOIN dba_data_grants dg ON dg.grant_name = m.grant_name
|
||||
WHERE u.active = 'Y'
|
||||
ORDER BY m.application_user_id;
|
||||
|
||||
PROMPT Catalog validation passed. Next: call /dds/mcp/sse and dds_vector_search with a valid MCP Bearer.
|
||||
EXIT;
|
||||
471
database/adb/46_kb_stakeholder_token_permission_example.sql
Normal file
471
database/adb/46_kb_stakeholder_token_permission_example.sql
Normal file
@@ -0,0 +1,471 @@
|
||||
-- ============================================================
|
||||
-- 46_kb_stakeholder_token_permission_example.sql
|
||||
--
|
||||
-- KB_STAKEHOLDERS를 토큰의 업무 주체로 연결하고, 접근 규칙 화면에서
|
||||
-- 관리하는 설계사/지점장 예제 권한을 등록한다.
|
||||
--
|
||||
-- Prerequisite: 17, 25, 26 scripts. Execute as ADMIN.
|
||||
-- 26 must include STAKEHOLDER_SELF / STAKEHOLDER_CHANNEL rule support.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === 1. Adding stakeholder bridge columns ===
|
||||
DECLARE
|
||||
PROCEDURE add_column_if_missing(
|
||||
p_table_name IN VARCHAR2,
|
||||
p_column_name IN VARCHAR2,
|
||||
p_ddl IN VARCHAR2
|
||||
) IS
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM user_tab_columns
|
||||
WHERE table_name = p_table_name
|
||||
AND column_name = p_column_name;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
EXECUTE IMMEDIATE p_ddl;
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
add_column_if_missing(
|
||||
'CB_APP_USER',
|
||||
'STAKEHOLDER_USER_ID',
|
||||
'ALTER TABLE cb_app_user ADD (stakeholder_user_id VARCHAR2(30))'
|
||||
);
|
||||
add_column_if_missing(
|
||||
'CB_AGENT_BEARER_KEY',
|
||||
'STAKEHOLDER_USER_ID',
|
||||
'ALTER TABLE cb_agent_bearer_key ADD (stakeholder_user_id VARCHAR2(30))'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 2. Resolving Bearer Token to stakeholder context ===
|
||||
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_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;
|
||||
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;
|
||||
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);
|
||||
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';
|
||||
|
||||
-- USER_ID를 먼저 넣어야 KB_STAKEHOLDERS 자체가 VPD 보호된 뒤에도
|
||||
-- 해당 주체의 자기 행만 안전하게 해석할 수 있다.
|
||||
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;
|
||||
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
|
||||
|
||||
PROMPT === 3. Syncing stakeholder bridge and example permissions ===
|
||||
DECLARE
|
||||
v_designer_role_id NUMBER;
|
||||
v_manager_role_id NUMBER;
|
||||
v_object_id NUMBER;
|
||||
v_user_id NUMBER;
|
||||
|
||||
PROCEDURE ensure_role(
|
||||
p_role_name IN VARCHAR2,
|
||||
p_max_level IN VARCHAR2,
|
||||
p_role_id OUT NUMBER
|
||||
) IS
|
||||
BEGIN
|
||||
SELECT role_id INTO p_role_id
|
||||
FROM cb_app_role
|
||||
WHERE role_name = p_role_name;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(role_id), 0) + 1 INTO p_role_id FROM cb_app_role;
|
||||
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
|
||||
VALUES (p_role_id, p_role_name, p_max_level);
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_object(
|
||||
p_object_name IN VARCHAR2,
|
||||
p_description IN VARCHAR2,
|
||||
p_object_id OUT NUMBER
|
||||
) IS
|
||||
BEGIN
|
||||
SELECT object_id INTO p_object_id
|
||||
FROM cb_protected_object
|
||||
WHERE owner = 'POC_2'
|
||||
AND object_name = p_object_name;
|
||||
|
||||
UPDATE cb_protected_object
|
||||
SET enabled_yn = 'Y',
|
||||
ords_path = 'cb-ords/cb-object-query/poc_2/' || LOWER(p_object_name),
|
||||
description = p_description
|
||||
WHERE object_id = p_object_id;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(object_id), 0) + 1 INTO p_object_id FROM cb_protected_object;
|
||||
INSERT INTO cb_protected_object(
|
||||
object_id, owner, object_name, ords_path, enabled_yn, description
|
||||
) VALUES (
|
||||
p_object_id, 'POC_2', p_object_name,
|
||||
'cb-ords/cb-object-query/poc_2/' || LOWER(p_object_name), 'Y', p_description
|
||||
);
|
||||
END;
|
||||
|
||||
FUNCTION is_sensitive(p_object_name IN VARCHAR2, p_column_name IN VARCHAR2) RETURN CHAR IS
|
||||
BEGIN
|
||||
IF (p_object_name = 'KB_CUSTOMERS' AND p_column_name IN ('CUST_NM', 'RRN_MASKED'))
|
||||
OR (p_object_name = 'KB_CLAIMS' AND p_column_name IN ('CLAIM_AMT', 'PAID_AMT'))
|
||||
OR (p_object_name = 'KB_EXTERNAL_HOLDINGS'
|
||||
AND p_column_name IN ('EXT_INSURER', 'EXT_PRODUCT_GRP', 'EXT_PRODUCT_TYPE')) THEN
|
||||
RETURN 'Y';
|
||||
END IF;
|
||||
RETURN 'N';
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_columns(p_object_name IN VARCHAR2, p_object_id IN NUMBER) IS
|
||||
v_column_id NUMBER;
|
||||
v_count NUMBER;
|
||||
v_sensitive CHAR(1);
|
||||
BEGIN
|
||||
FOR c IN (
|
||||
SELECT column_name
|
||||
FROM all_tab_columns
|
||||
WHERE owner = 'POC_2'
|
||||
AND table_name = p_object_name
|
||||
ORDER BY column_id
|
||||
) LOOP
|
||||
v_sensitive := is_sensitive(p_object_name, c.column_name);
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM cb_protected_column
|
||||
WHERE object_id = p_object_id
|
||||
AND column_name = c.column_name;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
SELECT NVL(MAX(column_id), 0) + 1 INTO v_column_id FROM cb_protected_column;
|
||||
INSERT INTO cb_protected_column(
|
||||
column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
|
||||
) VALUES (
|
||||
v_column_id, p_object_id, c.column_name, v_sensitive,
|
||||
CASE v_sensitive WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
|
||||
CASE v_sensitive WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
|
||||
);
|
||||
ELSIF v_sensitive = 'Y' THEN
|
||||
UPDATE cb_protected_column
|
||||
SET sensitive_yn = 'Y', sensitivity_level = 'CONFIDENTIAL', redaction_method = 'NULLIFY'
|
||||
WHERE object_id = p_object_id
|
||||
AND column_name = c.column_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_user_role(p_user_id IN NUMBER, p_role_id IN NUMBER) IS
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM cb_user_role
|
||||
WHERE user_id = p_user_id
|
||||
AND role_id = p_role_id;
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (p_user_id, p_role_id);
|
||||
END IF;
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_permission(
|
||||
p_role_id IN NUMBER,
|
||||
p_target_name IN VARCHAR2,
|
||||
p_rule_column IN VARCHAR2,
|
||||
p_rule_type IN VARCHAR2,
|
||||
p_rule_value IN VARCHAR2
|
||||
) IS
|
||||
v_permission_id NUMBER;
|
||||
BEGIN
|
||||
SELECT perm_id INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = p_role_id
|
||||
AND target_name = p_target_name
|
||||
AND action_name = 'SELECT';
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual;
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
|
||||
VALUES (v_permission_id, p_role_id, p_target_name, 'SELECT', 'ALLOW');
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, p_rule_column, p_rule_type, p_rule_value);
|
||||
END;
|
||||
BEGIN
|
||||
ensure_role('KB_DESIGNER_ROLE', 'CONFIDENTIAL', v_designer_role_id);
|
||||
ensure_role('KB_BRANCH_MANAGER_ROLE', 'CONFIDENTIAL', v_manager_role_id);
|
||||
|
||||
FOR s IN (
|
||||
SELECT user_id, user_nm, role, channel
|
||||
FROM poc_2.kb_stakeholders
|
||||
ORDER BY user_id
|
||||
) LOOP
|
||||
BEGIN
|
||||
SELECT user_id INTO v_user_id
|
||||
FROM cb_app_user
|
||||
WHERE stakeholder_user_id = s.user_id;
|
||||
|
||||
UPDATE cb_app_user
|
||||
SET user_name = s.user_nm,
|
||||
employee_no = s.user_id,
|
||||
dept_code = s.channel,
|
||||
active = 'Y'
|
||||
WHERE user_id = v_user_id;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(user_id), 0) + 1 INTO v_user_id FROM cb_app_user;
|
||||
INSERT INTO cb_app_user(
|
||||
user_id, user_name, employee_no, dept_code, stakeholder_user_id, can_read_contents, active
|
||||
) VALUES (
|
||||
v_user_id, s.user_nm, s.user_id, s.channel, s.user_id, 'N', 'Y'
|
||||
);
|
||||
END;
|
||||
|
||||
IF s.role = '지점장' THEN
|
||||
ensure_user_role(v_user_id, v_manager_role_id);
|
||||
ELSIF s.role = '설계사' THEN
|
||||
ensure_user_role(v_user_id, v_designer_role_id);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
FOR o IN (
|
||||
SELECT 'KB_STAKEHOLDERS' object_name, '토큰 업무 주체·역할·채널 원장' description FROM dual
|
||||
UNION ALL SELECT 'KB_CUSTOMERS', '고객 기본정보 원장' FROM dual
|
||||
UNION ALL SELECT 'KB_PRODUCTS', '보험상품 마스터' FROM dual
|
||||
UNION ALL SELECT 'KB_CONTRACTS', '보험계약 원장' FROM dual
|
||||
UNION ALL SELECT 'KB_COVERAGES', '담보·특약 원장' FROM dual
|
||||
UNION ALL SELECT 'KB_CLAIMS', '보험금 청구·지급 원장' FROM dual
|
||||
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', '외부보유정보 원장' FROM dual
|
||||
) LOOP
|
||||
ensure_object(o.object_name, o.description, v_object_id);
|
||||
ensure_columns(o.object_name, v_object_id);
|
||||
END LOOP;
|
||||
|
||||
-- 토큰은 모든 이해당사자에게 발급되지만, 아래 역할이 없는 주체는
|
||||
-- 권한 레코드가 없으므로 VPD가 1=0으로 fail-closed 된다.
|
||||
ensure_permission(v_designer_role_id, 'KB_PRODUCTS', NULL, 'ALL', NULL);
|
||||
ensure_permission(v_manager_role_id, 'KB_PRODUCTS', NULL, 'ALL', NULL);
|
||||
ensure_permission(v_designer_role_id, 'KB_CONTRACTS', 'FC_ID', 'OWN_CONTRACT', NULL);
|
||||
ensure_permission(v_manager_role_id, 'KB_CONTRACTS', 'FC_CHANNEL', 'CHANNEL_CONTRACT', NULL);
|
||||
ensure_permission(v_designer_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'OWN_CUSTOMER', NULL);
|
||||
ensure_permission(v_manager_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
|
||||
ensure_permission(v_designer_role_id, 'KB_COVERAGES', 'CUST_ID', 'OWN_CUSTOMER', NULL);
|
||||
ensure_permission(v_manager_role_id, 'KB_COVERAGES', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
|
||||
ensure_permission(v_designer_role_id, 'KB_CLAIMS', 'CUST_ID', 'OWN_CUSTOMER', NULL);
|
||||
ensure_permission(v_manager_role_id, 'KB_CLAIMS', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
|
||||
ensure_permission(v_designer_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'OWN_CUSTOMER', NULL);
|
||||
ensure_permission(v_manager_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
|
||||
END;
|
||||
/
|
||||
COMMIT;
|
||||
|
||||
PROMPT === 4. Applying row VPD policies to KB business tables ===
|
||||
DECLARE
|
||||
PROCEDURE replace_row_policy(p_object_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
BEGIN
|
||||
DBMS_RLS.DROP_POLICY(
|
||||
object_schema => 'POC_2', object_name => p_object_name,
|
||||
policy_name => 'KB_' || p_object_name || '_ROW_POLICY'
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END;
|
||||
DBMS_RLS.ADD_POLICY(
|
||||
object_schema => 'POC_2',
|
||||
object_name => p_object_name,
|
||||
policy_name => 'KB_' || p_object_name || '_ROW_POLICY',
|
||||
function_schema => USER,
|
||||
policy_function => 'CB_AGENT_DOC_VPD_FILTER',
|
||||
statement_types => 'SELECT',
|
||||
policy_type => DBMS_RLS.DYNAMIC,
|
||||
enable => TRUE
|
||||
);
|
||||
END;
|
||||
BEGIN
|
||||
replace_row_policy('KB_STAKEHOLDERS');
|
||||
replace_row_policy('KB_CUSTOMERS');
|
||||
replace_row_policy('KB_PRODUCTS');
|
||||
replace_row_policy('KB_CONTRACTS');
|
||||
replace_row_policy('KB_COVERAGES');
|
||||
replace_row_policy('KB_CLAIMS');
|
||||
replace_row_policy('KB_EXTERNAL_HOLDINGS');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 5. Removing deprecated VPD column NULL policies ===
|
||||
-- Architecture rule: VPD applies row predicates only. ASO/Data Redaction
|
||||
-- owns all column masking and its user-specific original-value exceptions.
|
||||
DECLARE
|
||||
PROCEDURE drop_column_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
DBMS_RLS.DROP_POLICY(
|
||||
object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -28102 THEN RAISE; END IF;
|
||||
END;
|
||||
BEGIN
|
||||
drop_column_policy('KB_CUSTOMERS', 'KB_CUST_NM_CLS_POLICY');
|
||||
drop_column_policy('KB_CUSTOMERS', 'KB_RRN_CLS_POLICY');
|
||||
drop_column_policy('KB_CLAIMS', 'KB_CLAIM_AMT_CLS_POLICY');
|
||||
drop_column_policy('KB_CLAIMS', 'KB_PAID_AMT_CLS_POLICY');
|
||||
drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_INSURER_CLS_POLICY');
|
||||
drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_GRP_CLS_POLICY');
|
||||
drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 6. Example configuration summary ===
|
||||
SELECT r.role_name,
|
||||
p.target_name,
|
||||
pr.rule_column,
|
||||
pr.rule_type,
|
||||
NVL(pr.rule_value, '-') AS rule_value
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role r ON r.role_id = p.role_id
|
||||
JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id
|
||||
WHERE r.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
|
||||
ORDER BY r.role_name, p.target_name, pr.rule_id;
|
||||
|
||||
PROMPT === KB stakeholder token permission example complete ===
|
||||
159
database/adb/47_dds_ad_identity_test_setup.sql
Normal file
159
database/adb/47_dds_ad_identity_test_setup.sql
Normal file
@@ -0,0 +1,159 @@
|
||||
-- ============================================================
|
||||
-- 47_dds_ad_identity_test_setup.sql
|
||||
--
|
||||
-- Test-only bridge for the isolated dds.test Active Directory domain.
|
||||
-- Maps immutable AD objectGUID values to the existing HMM application users
|
||||
-- 1 and 2, then publishes only their passwordless local DDS END USERs.
|
||||
--
|
||||
-- This does NOT validate an AD/OIDC JWT. A verified issuer + subject must be
|
||||
-- resolved by the MCP application before it uses this bridge.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === 1. Creating external identity bridge ===
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE q'[
|
||||
CREATE TABLE cb_external_identity_binding (
|
||||
issuer VARCHAR2(512) NOT NULL,
|
||||
subject VARCHAR2(512) NOT NULL,
|
||||
application_user_id NUMBER NOT NULL,
|
||||
display_name VARCHAR2(256),
|
||||
active CHAR(1) DEFAULT 'Y' NOT NULL
|
||||
CHECK (active IN ('Y', 'N')),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT cb_external_identity_binding_pk PRIMARY KEY (issuer, subject)
|
||||
)]';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'CREATE INDEX cb_external_identity_binding_user_ix '
|
||||
|| 'ON cb_external_identity_binding (application_user_id)';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 2. Creating DDS END USER map ===
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE q'[
|
||||
CREATE TABLE cb_dds_end_user_map (
|
||||
application_user_id NUMBER PRIMARY KEY,
|
||||
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||
lookup_key_ref VARCHAR2(128) NOT NULL,
|
||||
grant_name VARCHAR2(128) NOT NULL,
|
||||
publish_status VARCHAR2(20) NOT NULL
|
||||
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
|
||||
published_at TIMESTAMP,
|
||||
last_error VARCHAR2(1000)
|
||||
)]';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
DECLARE
|
||||
v_active_users NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_active_users
|
||||
FROM cb_app_user
|
||||
WHERE user_id IN (1, 2) AND active = 'Y';
|
||||
IF v_active_users <> 2 THEN
|
||||
RAISE_APPLICATION_ERROR(-20947, 'Expected active HMM application users 1 and 2.');
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 3. Binding dds.test immutable subjects to HMM users ===
|
||||
MERGE INTO cb_external_identity_binding target
|
||||
USING (
|
||||
SELECT 'urn:dds-ad:test' AS issuer,
|
||||
'fa7ebe4b-aca8-4049-a436-7f3da2d27a9f' AS subject,
|
||||
1 AS application_user_id,
|
||||
'dds-alice' AS display_name
|
||||
FROM dual
|
||||
UNION ALL
|
||||
SELECT 'urn:dds-ad:test',
|
||||
'128546cf-ec0a-4e99-8f51-dfce29280c91',
|
||||
2,
|
||||
'dds-bob'
|
||||
FROM dual
|
||||
) source
|
||||
ON (target.issuer = source.issuer AND target.subject = source.subject)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
target.application_user_id = source.application_user_id,
|
||||
target.display_name = source.display_name,
|
||||
target.active = 'Y',
|
||||
target.updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
issuer, subject, application_user_id, display_name, active
|
||||
) VALUES (
|
||||
source.issuer, source.subject, source.application_user_id, source.display_name, 'Y'
|
||||
);
|
||||
|
||||
MERGE INTO cb_dds_end_user_map target
|
||||
USING (
|
||||
SELECT user_id AS application_user_id,
|
||||
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
|
||||
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
|
||||
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
|
||||
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
|
||||
FROM cb_app_user
|
||||
WHERE user_id IN (1, 2)
|
||||
) source
|
||||
ON (target.application_user_id = source.application_user_id)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
target.end_user_name = source.end_user_name,
|
||||
target.data_role_name = source.data_role_name,
|
||||
target.lookup_key_ref = source.lookup_key_ref,
|
||||
target.grant_name = source.grant_name,
|
||||
target.publish_status = 'PENDING',
|
||||
target.last_error = NULL
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
application_user_id, end_user_name, data_role_name, lookup_key_ref,
|
||||
grant_name, publish_status
|
||||
) VALUES (
|
||||
source.application_user_id, source.end_user_name, source.data_role_name,
|
||||
source.lookup_key_ref, source.grant_name, 'PENDING'
|
||||
);
|
||||
|
||||
PROMPT === 4. Publishing DDS identities only (default deny until data grants exist) ===
|
||||
BEGIN
|
||||
FOR mapped_user IN (
|
||||
SELECT application_user_id, end_user_name, data_role_name
|
||||
FROM cb_dds_end_user_map
|
||||
WHERE application_user_id IN (1, 2)
|
||||
ORDER BY application_user_id
|
||||
) LOOP
|
||||
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
|
||||
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
|
||||
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|
||||
|| ' TO "' || mapped_user.end_user_name || '"';
|
||||
UPDATE cb_dds_end_user_map
|
||||
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
|
||||
WHERE application_user_id = mapped_user.application_user_id;
|
||||
END LOOP;
|
||||
COMMIT;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 5. Identity bridge inventory ===
|
||||
SELECT b.issuer, b.subject, b.display_name, b.application_user_id,
|
||||
m.end_user_name, m.data_role_name, m.publish_status
|
||||
FROM cb_external_identity_binding b
|
||||
JOIN cb_dds_end_user_map m ON m.application_user_id = b.application_user_id
|
||||
WHERE b.issuer = 'urn:dds-ad:test'
|
||||
ORDER BY b.application_user_id;
|
||||
|
||||
PROMPT === AD identity to DDS END USER test setup complete ===
|
||||
EXIT;
|
||||
104
database/adb/47_kb_stakeholder_permission_or_branches.sql
Normal file
104
database/adb/47_kb_stakeholder_permission_or_branches.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- ============================================================
|
||||
-- 47_kb_stakeholder_permission_or_branches.sql
|
||||
--
|
||||
-- Refines the KB example so the permission table carries the expected
|
||||
-- stakeholder role and each role branch becomes an OR candidate in the
|
||||
-- dynamic VPD filter. Execute after 26 and 46 as ADMIN.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === 1. Storing expected stakeholder role in example rules ===
|
||||
UPDATE cb_permission_rule r
|
||||
SET rule_value = CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id
|
||||
AND ar.role_name = 'KB_DESIGNER_ROLE'
|
||||
) THEN '설계사'
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id
|
||||
AND ar.role_name = 'KB_BRANCH_MANAGER_ROLE'
|
||||
) THEN '지점장'
|
||||
END
|
||||
WHERE r.rule_type IN ('STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id
|
||||
AND ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
|
||||
);
|
||||
|
||||
PROMPT === 2. Adding an identity role so every stakeholder token can resolve itself ===
|
||||
DECLARE
|
||||
v_role_id NUMBER;
|
||||
v_permission_id NUMBER;
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
BEGIN
|
||||
SELECT role_id INTO v_role_id
|
||||
FROM cb_app_role
|
||||
WHERE role_name = 'KB_STAKEHOLDER_IDENTITY_ROLE';
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(role_id), 0) + 1 INTO v_role_id FROM cb_app_role;
|
||||
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
|
||||
VALUES (v_role_id, 'KB_STAKEHOLDER_IDENTITY_ROLE', 'INTERNAL');
|
||||
END;
|
||||
|
||||
FOR u IN (
|
||||
SELECT user_id
|
||||
FROM cb_app_user
|
||||
WHERE stakeholder_user_id IS NOT NULL
|
||||
AND active = 'Y'
|
||||
) LOOP
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM cb_user_role
|
||||
WHERE user_id = u.user_id
|
||||
AND role_id = v_role_id;
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (u.user_id, v_role_id);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
BEGIN
|
||||
SELECT perm_id INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = v_role_id
|
||||
AND target_name = 'KB_STAKEHOLDERS'
|
||||
AND action_name = 'SELECT';
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual;
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
|
||||
VALUES (v_permission_id, v_role_id, 'KB_STAKEHOLDERS', 'SELECT', 'ALLOW');
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, 'USER_ID', 'TOKEN_SUBJECT', NULL);
|
||||
END;
|
||||
END;
|
||||
/
|
||||
COMMIT;
|
||||
|
||||
PROMPT === 3. Result: role-specific permission branches ===
|
||||
SELECT ar.role_name,
|
||||
p.target_name,
|
||||
r.rule_column,
|
||||
r.rule_type,
|
||||
NVL(r.rule_value, '(token role)') AS expected_stakeholder_role
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
JOIN cb_permission_rule r ON r.perm_id = p.perm_id
|
||||
WHERE ar.role_name IN (
|
||||
'KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE', 'KB_STAKEHOLDER_IDENTITY_ROLE'
|
||||
)
|
||||
ORDER BY ar.role_name, p.target_name, r.rule_id;
|
||||
|
||||
PROMPT === KB role branches are combined as OR by CB_AGENT_DOC_VPD_FILTER ===
|
||||
137
database/adb/48_kb_permission_condition_codes.sql
Normal file
137
database/adb/48_kb_permission_condition_codes.sql
Normal file
@@ -0,0 +1,137 @@
|
||||
-- ============================================================
|
||||
-- 48_kb_permission_condition_codes.sql
|
||||
--
|
||||
-- Replaces the first KB example's stakeholder-specific predicate types with
|
||||
-- policy condition codes. CB_AGENT_DOC_VPD_FILTER expands these codes into
|
||||
-- the target object's WHERE predicate.
|
||||
-- Execute as ADMIN after 25 and 46.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
ALTER SESSION DISABLE PARALLEL DML;
|
||||
|
||||
PROMPT === 1. Replacing KB example rules with filter condition codes ===
|
||||
UPDATE cb_permission_rule r
|
||||
SET rule_type = CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id AND ar.role_name = 'KB_DESIGNER_ROLE'
|
||||
AND p.target_name = 'KB_CONTRACTS'
|
||||
) THEN 'OWN_CONTRACT'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id AND ar.role_name = 'KB_BRANCH_MANAGER_ROLE'
|
||||
AND p.target_name = 'KB_CONTRACTS'
|
||||
) THEN 'CHANNEL_CONTRACT'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id AND ar.role_name = 'KB_DESIGNER_ROLE'
|
||||
AND p.target_name IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
|
||||
) THEN 'OWN_CUSTOMER'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id AND ar.role_name = 'KB_BRANCH_MANAGER_ROLE'
|
||||
AND p.target_name IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
|
||||
) THEN 'CHANNEL_CUSTOMER'
|
||||
ELSE r.rule_type
|
||||
END,
|
||||
rule_value = NULL
|
||||
WHERE r.rule_type IN ('STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE p.perm_id = r.perm_id
|
||||
AND ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
|
||||
AND p.target_name IN ('KB_CONTRACTS', 'KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
PROMPT === 2. Keeping token subject access as an identity-only permission ===
|
||||
DECLARE
|
||||
v_identity_role_id NUMBER;
|
||||
v_permission_id NUMBER;
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
BEGIN
|
||||
SELECT role_id INTO v_identity_role_id
|
||||
FROM cb_app_role
|
||||
WHERE role_name = 'KB_STAKEHOLDER_IDENTITY_ROLE';
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(role_id), 0) + 1 INTO v_identity_role_id FROM cb_app_role;
|
||||
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
|
||||
VALUES (v_identity_role_id, 'KB_STAKEHOLDER_IDENTITY_ROLE', 'INTERNAL');
|
||||
END;
|
||||
|
||||
FOR u IN (
|
||||
SELECT user_id FROM cb_app_user
|
||||
WHERE stakeholder_user_id IS NOT NULL AND active = 'Y'
|
||||
) LOOP
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM cb_user_role
|
||||
WHERE user_id = u.user_id AND role_id = v_identity_role_id;
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (u.user_id, v_identity_role_id);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- Business roles do not need to grant the stakeholder source row. The
|
||||
-- identity role alone lets a valid token resolve its own stakeholder row.
|
||||
DELETE FROM cb_permission_column
|
||||
WHERE permission_id IN (
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
|
||||
AND p.target_name = 'KB_STAKEHOLDERS'
|
||||
);
|
||||
DELETE FROM cb_permission_rule
|
||||
WHERE perm_id IN (
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
|
||||
AND p.target_name = 'KB_STAKEHOLDERS'
|
||||
);
|
||||
DELETE FROM cb_permission
|
||||
WHERE perm_id IN (
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
WHERE ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
|
||||
AND p.target_name = 'KB_STAKEHOLDERS'
|
||||
);
|
||||
|
||||
BEGIN
|
||||
SELECT perm_id INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = v_identity_role_id
|
||||
AND target_name = 'KB_STAKEHOLDERS'
|
||||
AND action_name = 'SELECT';
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual;
|
||||
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
|
||||
VALUES (v_permission_id, v_identity_role_id, 'KB_STAKEHOLDERS', 'SELECT', 'ALLOW');
|
||||
END;
|
||||
|
||||
DELETE FROM cb_permission_rule WHERE perm_id = v_permission_id;
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, 'USER_ID', 'TOKEN_SUBJECT', NULL);
|
||||
END;
|
||||
/
|
||||
COMMIT;
|
||||
|
||||
PROMPT === 3. Condition codes consumed by the VPD filter ===
|
||||
SELECT ar.role_name, p.target_name, r.rule_column, r.rule_type
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role ar ON ar.role_id = p.role_id
|
||||
JOIN cb_permission_rule r ON r.perm_id = p.perm_id
|
||||
WHERE ar.role_name IN (
|
||||
'KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE', 'KB_STAKEHOLDER_IDENTITY_ROLE'
|
||||
)
|
||||
ORDER BY ar.role_name, p.target_name, r.rule_id;
|
||||
|
||||
PROMPT === KB permission condition codes ready ===
|
||||
35
database/adb/49_permission_static_sql_condition.sql
Normal file
35
database/adb/49_permission_static_sql_condition.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- ============================================================
|
||||
-- 49_permission_static_sql_condition.sql
|
||||
--
|
||||
-- Makes room for a literal, static WHERE condition attached to an
|
||||
-- allowlisted permission rule. The VPD function validates the expression
|
||||
-- again before returning it as part of a predicate.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === Enlarging rule value for static SQL conditions ===
|
||||
DECLARE
|
||||
v_data_length NUMBER;
|
||||
BEGIN
|
||||
SELECT data_length
|
||||
INTO v_data_length
|
||||
FROM user_tab_columns
|
||||
WHERE table_name = 'CB_PERMISSION_RULE'
|
||||
AND column_name = 'RULE_VALUE';
|
||||
|
||||
IF v_data_length < 1000 THEN
|
||||
EXECUTE IMMEDIATE
|
||||
'ALTER TABLE cb_permission_rule MODIFY (rule_value VARCHAR2(1000 CHAR))';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
SELECT column_name, data_type, char_length
|
||||
FROM user_tab_columns
|
||||
WHERE table_name = 'CB_PERMISSION_RULE'
|
||||
AND column_name = 'RULE_VALUE';
|
||||
|
||||
PROMPT === Static SQL condition storage ready ===
|
||||
228
database/adb/51_kb_vpd_admin_token.sql
Normal file
228
database/adb/51_kb_vpd_admin_token.sql
Normal file
@@ -0,0 +1,228 @@
|
||||
-- ============================================================
|
||||
-- 51_kb_vpd_admin_token.sql
|
||||
--
|
||||
-- Creates an application-level KB VPD administrator. This is not an
|
||||
-- EXEMPT ACCESS POLICY database account: the token is resolved to an
|
||||
-- application context and the existing VPD policy function returns ALL
|
||||
-- only because KB_VPD_ADMIN_ROLE has explicit ALL permissions.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
SET SERVEROUTPUT ON
|
||||
|
||||
PROMPT === Creating KB VPD administrator, permissions, and one-time token ===
|
||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO POC_2;
|
||||
|
||||
DECLARE
|
||||
c_stakeholder_user_id CONSTANT VARCHAR2(30) := 'KB_VPD_ADMIN';
|
||||
c_role_name CONSTANT VARCHAR2(80) := 'KB_VPD_ADMIN_ROLE';
|
||||
c_token_description CONSTANT VARCHAR2(200) := 'KB VPD admin full-access token';
|
||||
|
||||
v_admin_role_id NUMBER;
|
||||
v_identity_role_id NUMBER;
|
||||
v_app_user_id NUMBER;
|
||||
v_permission_id NUMBER;
|
||||
v_bearer_token VARCHAR2(128);
|
||||
v_count NUMBER;
|
||||
|
||||
PROCEDURE ensure_role(
|
||||
p_role_name IN VARCHAR2,
|
||||
p_level IN VARCHAR2,
|
||||
p_role_id OUT NUMBER
|
||||
) IS
|
||||
BEGIN
|
||||
SELECT role_id
|
||||
INTO p_role_id
|
||||
FROM cb_app_role
|
||||
WHERE role_name = p_role_name;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(role_id), 0) + 1
|
||||
INTO p_role_id
|
||||
FROM cb_app_role;
|
||||
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
|
||||
VALUES (p_role_id, p_role_name, p_level);
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_user_role(p_user_id IN NUMBER, p_role_id IN NUMBER) IS
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM cb_user_role
|
||||
WHERE user_id = p_user_id
|
||||
AND role_id = p_role_id;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (p_user_id, p_role_id);
|
||||
END IF;
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_all_permission(p_target_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
SELECT MIN(perm_id)
|
||||
INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = v_admin_role_id
|
||||
AND target_name = p_target_name
|
||||
AND action_name = 'SELECT';
|
||||
|
||||
IF v_permission_id IS NULL THEN
|
||||
SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual;
|
||||
INSERT INTO cb_permission(
|
||||
perm_id, role_id, target_name, action_name, permission_effect
|
||||
) VALUES (
|
||||
v_permission_id, v_admin_role_id, p_target_name, 'SELECT', 'ALLOW'
|
||||
);
|
||||
ELSE
|
||||
UPDATE cb_permission
|
||||
SET permission_effect = 'ALLOW'
|
||||
WHERE perm_id = v_permission_id;
|
||||
END IF;
|
||||
|
||||
DELETE FROM cb_permission_rule WHERE perm_id = v_permission_id;
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, NULL, 'STATIC_SQL', '1 = 1');
|
||||
|
||||
-- CB_ORDS is the non-exempt data runtime. VPD still applies after
|
||||
-- this object privilege is granted.
|
||||
EXECUTE IMMEDIATE
|
||||
'GRANT SELECT ON POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_target_name) || ' TO CB_ORDS';
|
||||
END;
|
||||
|
||||
PROCEDURE allow_original_column(
|
||||
p_target_name IN VARCHAR2,
|
||||
p_column_name IN VARCHAR2
|
||||
) IS
|
||||
BEGIN
|
||||
SELECT MIN(perm_id)
|
||||
INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = v_admin_role_id
|
||||
AND target_name = p_target_name
|
||||
AND action_name = 'SELECT';
|
||||
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM cb_permission_column
|
||||
WHERE permission_id = v_permission_id
|
||||
AND column_name = p_column_name;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_permission_column(permission_id, column_name)
|
||||
VALUES (v_permission_id, p_column_name);
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
MERGE INTO poc_2.kb_stakeholders target
|
||||
USING (
|
||||
SELECT c_stakeholder_user_id AS user_id,
|
||||
'VPD 관리자' AS user_nm,
|
||||
'관리자' AS role,
|
||||
'관리자채널' AS channel,
|
||||
'전체 KB 원장 및 민감 컬럼 접근' AS access_scope,
|
||||
'권한 테이블의 KB_VPD_ADMIN_ROLE / ALL 규칙으로 적용' AS rls_filter
|
||||
FROM dual
|
||||
) source
|
||||
ON (target.user_id = source.user_id)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
target.user_nm = source.user_nm,
|
||||
target.role = source.role,
|
||||
target.channel = source.channel,
|
||||
target.access_scope = source.access_scope,
|
||||
target.rls_filter = source.rls_filter
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
user_id, user_nm, role, channel, access_scope, rls_filter
|
||||
) VALUES (
|
||||
source.user_id, source.user_nm, source.role, source.channel,
|
||||
source.access_scope, source.rls_filter
|
||||
);
|
||||
|
||||
BEGIN
|
||||
SELECT user_id
|
||||
INTO v_app_user_id
|
||||
FROM cb_app_user
|
||||
WHERE stakeholder_user_id = c_stakeholder_user_id;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(user_id), 0) + 1 INTO v_app_user_id FROM cb_app_user;
|
||||
INSERT INTO cb_app_user(
|
||||
user_id, user_name, employee_no, dept_code,
|
||||
stakeholder_user_id, can_read_contents, active
|
||||
) VALUES (
|
||||
v_app_user_id, 'VPD 관리자', c_stakeholder_user_id, '관리자채널',
|
||||
c_stakeholder_user_id, 'Y', 'Y'
|
||||
);
|
||||
END;
|
||||
|
||||
UPDATE cb_app_user
|
||||
SET user_name = 'VPD 관리자',
|
||||
employee_no = c_stakeholder_user_id,
|
||||
dept_code = '관리자채널',
|
||||
can_read_contents = 'Y',
|
||||
active = 'Y'
|
||||
WHERE user_id = v_app_user_id;
|
||||
|
||||
ensure_role(c_role_name, 'RESTRICTED', v_admin_role_id);
|
||||
ensure_role('KB_STAKEHOLDER_IDENTITY_ROLE', 'INTERNAL', v_identity_role_id);
|
||||
ensure_user_role(v_app_user_id, v_admin_role_id);
|
||||
ensure_user_role(v_app_user_id, v_identity_role_id);
|
||||
|
||||
FOR object_name IN (
|
||||
SELECT column_value
|
||||
FROM TABLE(sys.odcivarchar2list(
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
))
|
||||
) LOOP
|
||||
ensure_all_permission(object_name.column_value);
|
||||
END LOOP;
|
||||
|
||||
allow_original_column('KB_CUSTOMERS', 'CUST_NM');
|
||||
allow_original_column('KB_CUSTOMERS', 'RRN_MASKED');
|
||||
allow_original_column('KB_CLAIMS', 'CLAIM_AMT');
|
||||
allow_original_column('KB_CLAIMS', 'PAID_AMT');
|
||||
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER');
|
||||
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP');
|
||||
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE');
|
||||
|
||||
-- Re-running this script rotates the only full-access administrator token.
|
||||
UPDATE cb_agent_bearer_key
|
||||
SET active = 'N',
|
||||
revoked_at = SYSDATE
|
||||
WHERE stakeholder_user_id = c_stakeholder_user_id
|
||||
AND active = 'Y';
|
||||
|
||||
v_bearer_token := 'kbvpdadm_' || DBMS_RANDOM.STRING('x', 48);
|
||||
INSERT INTO cb_agent_bearer_key(
|
||||
key_id, user_id, stakeholder_user_id, key_prefix, key_hash,
|
||||
issued_at, expires_at, revoked_at, active, description
|
||||
) VALUES (
|
||||
cb_agent_bearer_key_seq.NEXTVAL, v_app_user_id, c_stakeholder_user_id,
|
||||
'kbvpdadm', STANDARD_HASH(v_bearer_token, 'SHA256'),
|
||||
SYSDATE, ADD_MONTHS(SYSDATE, 12), NULL, 'Y', c_token_description
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- This is the application step that binds the Bearer Token to the trusted
|
||||
-- context. DBMS_RLS then invokes CB_AGENT_DOC_VPD_FILTER automatically.
|
||||
cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_token);
|
||||
DBMS_OUTPUT.PUT_LINE('STAKEHOLDER_USER_ID='
|
||||
|| SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'));
|
||||
DBMS_OUTPUT.PUT_LINE('EFFECTIVE_ROLE=' || c_role_name);
|
||||
DBMS_OUTPUT.PUT_LINE('KB_CUSTOMERS_PREDICATE='
|
||||
|| cb_agent_doc_vpd_filter('POC_2', 'KB_CUSTOMERS'));
|
||||
DBMS_OUTPUT.PUT_LINE('KB_CLAIMS_PREDICATE='
|
||||
|| cb_agent_doc_vpd_filter('POC_2', 'KB_CLAIMS'));
|
||||
DBMS_OUTPUT.PUT_LINE('KB_EXTERNAL_HOLDINGS_PREDICATE='
|
||||
|| cb_agent_doc_vpd_filter('POC_2', 'KB_EXTERNAL_HOLDINGS'));
|
||||
cb_agent_ctx_pkg.clear_user;
|
||||
|
||||
-- Show once. The raw token is never stored; only its SHA-256 hash remains.
|
||||
DBMS_OUTPUT.PUT_LINE('BEARER_TOKEN=' || v_bearer_token);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === KB VPD administrator token ready ===
|
||||
223
database/adb/52_cleanup_to_kb_vpd_poc.sql
Normal file
223
database/adb/52_cleanup_to_kb_vpd_poc.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
-- ============================================================
|
||||
-- 52_cleanup_to_kb_vpd_poc.sql
|
||||
--
|
||||
-- Removes legacy document/Vector/DDS/test examples and keeps only
|
||||
-- the KB structured-data VPD POC:
|
||||
-- POC_2.KB_* seven ledgers, stakeholder identity, KB roles,
|
||||
-- bearer-token permission metadata, and their VPD policies.
|
||||
--
|
||||
-- Oracle-maintained ANNOTATIONS_* / VECTOR$* auxiliary objects are
|
||||
-- not dropped directly. Their owning RAG/vector tables are dropped.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
SET SERVEROUTPUT ON
|
||||
ALTER SESSION DISABLE PARALLEL DML;
|
||||
|
||||
PROMPT === 1. Removing document, legacy role, and test permissions ===
|
||||
DECLARE
|
||||
c_role_keep_1 CONSTANT VARCHAR2(80) := 'KB_DESIGNER_ROLE';
|
||||
c_role_keep_2 CONSTANT VARCHAR2(80) := 'KB_BRANCH_MANAGER_ROLE';
|
||||
c_role_keep_3 CONSTANT VARCHAR2(80) := 'KB_STAKEHOLDER_IDENTITY_ROLE';
|
||||
c_role_keep_4 CONSTANT VARCHAR2(80) := 'KB_VPD_ADMIN_ROLE';
|
||||
BEGIN
|
||||
DELETE FROM cb_permission_column
|
||||
WHERE permission_id IN (
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role r ON r.role_id = p.role_id
|
||||
WHERE r.role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
|
||||
OR p.target_name NOT IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM cb_permission_rule
|
||||
WHERE perm_id IN (
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role r ON r.role_id = p.role_id
|
||||
WHERE r.role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
|
||||
OR p.target_name NOT IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM cb_permission
|
||||
WHERE perm_id IN (
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p
|
||||
JOIN cb_app_role r ON r.role_id = p.role_id
|
||||
WHERE r.role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
|
||||
OR p.target_name NOT IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM cb_user_role
|
||||
WHERE role_id IN (
|
||||
SELECT role_id
|
||||
FROM cb_app_role
|
||||
WHERE role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
|
||||
);
|
||||
|
||||
DELETE FROM cb_group_role;
|
||||
DELETE FROM cb_user_group;
|
||||
DELETE FROM cb_app_group;
|
||||
|
||||
DELETE FROM cb_app_role
|
||||
WHERE role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4);
|
||||
|
||||
-- Keep only tokens attached to an actual KB stakeholder.
|
||||
DELETE FROM cb_agent_bearer_key
|
||||
WHERE stakeholder_user_id IS NULL;
|
||||
|
||||
-- Non-stakeholder users were document/test sample principals.
|
||||
DELETE FROM cb_app_user
|
||||
WHERE stakeholder_user_id IS NULL;
|
||||
|
||||
DELETE FROM cb_vpd_filter_note;
|
||||
DELETE FROM cb_vpd_policy_note;
|
||||
DELETE FROM cb_ords_probe_audit;
|
||||
|
||||
DELETE FROM cb_protected_column
|
||||
WHERE object_id IN (
|
||||
SELECT object_id
|
||||
FROM cb_protected_object
|
||||
WHERE NOT (
|
||||
owner = 'POC_2'
|
||||
AND object_name IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM cb_protected_object
|
||||
WHERE NOT (
|
||||
owner = 'POC_2'
|
||||
AND object_name IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 2. Removing legacy document VPD/redaction and source objects ===
|
||||
BEGIN
|
||||
DBMS_REDACT.DROP_POLICY(
|
||||
object_schema => 'ADMIN',
|
||||
object_name => 'CB_V_SEARCH_DOCUMENTS',
|
||||
policy_name => 'CB_CONTENTS_REDACT'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- The policy may already be absent in a reduced POC schema.
|
||||
DBMS_OUTPUT.PUT_LINE('redaction policy not removed: ' || SQLERRM);
|
||||
END;
|
||||
/
|
||||
|
||||
DECLARE
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_policies
|
||||
WHERE object_owner = 'ADMIN'
|
||||
AND object_name = 'CB_V_SEARCH_DOCUMENTS'
|
||||
AND policy_name = 'CB_AGENT_DOC_POLICY';
|
||||
|
||||
IF v_count > 0 THEN
|
||||
DBMS_RLS.DROP_POLICY(
|
||||
object_schema => 'ADMIN',
|
||||
object_name => 'CB_V_SEARCH_DOCUMENTS',
|
||||
policy_name => 'CB_AGENT_DOC_POLICY'
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -942 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -942 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 3. Dropping unrelated POC_2 backup, RAG, and security-example tables ===
|
||||
DECLARE
|
||||
PROCEDURE drop_table_if_exists(p_table_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE
|
||||
'DROP TABLE POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name)
|
||||
|| ' CASCADE CONSTRAINTS PURGE';
|
||||
DBMS_OUTPUT.PUT_LINE('dropped POC_2.' || p_table_name);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE = -942 THEN
|
||||
NULL;
|
||||
ELSE
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
drop_table_if_exists('BKP_CLM_20260701144052');
|
||||
drop_table_if_exists('BKP_CONT_20260701144052');
|
||||
drop_table_if_exists('BKP_COV_20260701144052');
|
||||
drop_table_if_exists('BKP_CUST_20260701144052');
|
||||
drop_table_if_exists('BKP_EXT_20260701144052');
|
||||
drop_table_if_exists('BKP_PROD_20260701144052');
|
||||
drop_table_if_exists('KB_CUST_BAK_202607011343');
|
||||
drop_table_if_exists('KB_CUST_BAK_20260701134439');
|
||||
drop_table_if_exists('KB_AIDP_RAG_CORPUS_CONFIG');
|
||||
drop_table_if_exists('KB_AIDP_RAG_QUERY_CACHE');
|
||||
drop_table_if_exists('KB_COMPETITOR_TERMS_CHUNKS');
|
||||
drop_table_if_exists('KB_COMPETITOR_TERMS_DOCUMENTS');
|
||||
drop_table_if_exists('KB_OWN_TERMS_CHUNKS');
|
||||
drop_table_if_exists('KB_OWN_TERMS_DOCUMENTS');
|
||||
drop_table_if_exists('KB_SECURITY_AUDIT_LOG');
|
||||
drop_table_if_exists('KB_SECURITY_POLICY_CATALOG');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 4. Remaining KB VPD POC inventory ===
|
||||
SELECT object_name
|
||||
FROM cb_protected_object
|
||||
WHERE owner = 'POC_2'
|
||||
ORDER BY object_name;
|
||||
|
||||
SELECT role_name
|
||||
FROM cb_app_role
|
||||
ORDER BY role_name;
|
||||
|
||||
SELECT key_id, stakeholder_user_id, key_prefix, active, expires_at
|
||||
FROM cb_agent_bearer_key
|
||||
ORDER BY key_id;
|
||||
|
||||
SELECT object_owner, object_name, policy_name, function, enable
|
||||
FROM all_policies
|
||||
WHERE object_owner = 'POC_2'
|
||||
ORDER BY object_name, policy_name;
|
||||
|
||||
PROMPT === KB VPD POC cleanup complete ===
|
||||
101
database/adb/53_drop_legacy_poc_objects.sql
Normal file
101
database/adb/53_drop_legacy_poc_objects.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- ============================================================
|
||||
-- 53_drop_legacy_poc_objects.sql
|
||||
-- Completes the physical-object portion of the KB VPD POC cleanup
|
||||
-- after 52 has removed legacy permissions, roles, tokens and users.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
SET SERVEROUTPUT ON
|
||||
|
||||
PROMPT === Removing legacy document VPD/redaction and source objects ===
|
||||
BEGIN
|
||||
DBMS_REDACT.DROP_POLICY(
|
||||
object_schema => 'ADMIN',
|
||||
object_name => 'CB_V_SEARCH_DOCUMENTS',
|
||||
policy_name => 'CB_CONTENTS_REDACT'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
DBMS_OUTPUT.PUT_LINE('redaction policy not removed: ' || SQLERRM);
|
||||
END;
|
||||
/
|
||||
|
||||
DECLARE
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_policies
|
||||
WHERE object_owner = 'ADMIN'
|
||||
AND object_name = 'CB_V_SEARCH_DOCUMENTS'
|
||||
AND policy_name = 'CB_AGENT_DOC_POLICY';
|
||||
|
||||
IF v_count > 0 THEN
|
||||
DBMS_RLS.DROP_POLICY(
|
||||
object_schema => 'ADMIN',
|
||||
object_name => 'CB_V_SEARCH_DOCUMENTS',
|
||||
policy_name => 'CB_AGENT_DOC_POLICY'
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -942 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -942 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Dropping unrelated POC_2 backup, RAG, and security-example tables ===
|
||||
DECLARE
|
||||
PROCEDURE drop_table_if_exists(p_table_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE
|
||||
'DROP TABLE POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name)
|
||||
|| ' CASCADE CONSTRAINTS PURGE';
|
||||
DBMS_OUTPUT.PUT_LINE('dropped POC_2.' || p_table_name);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE = -942 THEN
|
||||
NULL;
|
||||
ELSE
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
drop_table_if_exists('BKP_CLM_20260701144052');
|
||||
drop_table_if_exists('BKP_CONT_20260701144052');
|
||||
drop_table_if_exists('BKP_COV_20260701144052');
|
||||
drop_table_if_exists('BKP_CUST_20260701144052');
|
||||
drop_table_if_exists('BKP_EXT_20260701144052');
|
||||
drop_table_if_exists('BKP_PROD_20260701144052');
|
||||
drop_table_if_exists('KB_CUST_BAK_202607011343');
|
||||
drop_table_if_exists('KB_CUST_BAK_20260701134439');
|
||||
drop_table_if_exists('KB_AIDP_RAG_CORPUS_CONFIG');
|
||||
drop_table_if_exists('KB_AIDP_RAG_QUERY_CACHE');
|
||||
drop_table_if_exists('KB_COMPETITOR_TERMS_CHUNKS');
|
||||
drop_table_if_exists('KB_COMPETITOR_TERMS_DOCUMENTS');
|
||||
drop_table_if_exists('KB_OWN_TERMS_CHUNKS');
|
||||
drop_table_if_exists('KB_OWN_TERMS_DOCUMENTS');
|
||||
drop_table_if_exists('KB_SECURITY_AUDIT_LOG');
|
||||
drop_table_if_exists('KB_SECURITY_POLICY_CATALOG');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Legacy physical objects removed ===
|
||||
47
database/adb/54_drop_legacy_dds_ords_examples.sql
Normal file
47
database/adb/54_drop_legacy_dds_ords_examples.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- ============================================================
|
||||
-- 54_drop_legacy_dds_ords_examples.sql
|
||||
-- Removes only the former document-search ORDS/DDS example objects.
|
||||
-- It deliberately keeps CB_ORDS because it is the non-exempt KB VPD
|
||||
-- runtime principal.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
SET SERVEROUTPUT ON
|
||||
|
||||
BEGIN
|
||||
ORDS.DELETE_MODULE(p_module_name => 'cb.agent.security');
|
||||
DBMS_OUTPUT.PUT_LINE('dropped ORDS module cb.agent.security');
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
DBMS_OUTPUT.PUT_LINE('ORDS module not removed: ' || SQLERRM);
|
||||
END;
|
||||
/
|
||||
|
||||
DECLARE
|
||||
PROCEDURE drop_ddl_if_present(p_ddl IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE p_ddl;
|
||||
DBMS_OUTPUT.PUT_LINE('dropped: ' || p_ddl);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
DBMS_OUTPUT.PUT_LINE('not present: ' || p_ddl);
|
||||
END;
|
||||
BEGIN
|
||||
drop_ddl_if_present('DROP DATA GRANT admin.cb_dg_hr_docs');
|
||||
drop_ddl_if_present('DROP DATA GRANT admin.cb_dg_fin_docs');
|
||||
drop_ddl_if_present('DROP DATA GRANT admin.cb_dg_all_docs');
|
||||
drop_ddl_if_present('DROP END USER "cb_dds_hr"');
|
||||
drop_ddl_if_present('DROP END USER "cb_dds_fin"');
|
||||
drop_ddl_if_present('DROP END USER "cb_dds_all"');
|
||||
drop_ddl_if_present('DROP END USER "cb_dds_none"');
|
||||
drop_ddl_if_present('DROP DATA ROLE cb_dds_hr_role');
|
||||
drop_ddl_if_present('DROP DATA ROLE cb_dds_fin_role');
|
||||
drop_ddl_if_present('DROP DATA ROLE cb_dds_all_role');
|
||||
drop_ddl_if_present('DROP DATA ROLE cb_dds_connect_only_role');
|
||||
drop_ddl_if_present('DROP ROLE cb_dds_connect_role');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Legacy DDS/ORDS examples removed ===
|
||||
174
database/adb/55_reapply_kb_vpd_after_table_reload.sql
Normal file
174
database/adb/55_reapply_kb_vpd_after_table_reload.sql
Normal file
@@ -0,0 +1,174 @@
|
||||
-- ============================================================
|
||||
-- 55_reapply_kb_vpd_after_table_reload.sql
|
||||
--
|
||||
-- KB 원장을 DROP/CREATE 또는 재적재한 뒤 실행한다.
|
||||
-- 테이블 재생성으로 사라진 POC_2 KB VPD/CLS 정책과 CB_ORDS SELECT
|
||||
-- 권한만 복구한다. 사용자·역할·권한 조건과 Bearer 토큰은 변경하지 않는다.
|
||||
-- Execute as ADMIN using SQLcl.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === 1. Validating reloaded KB table contract ===
|
||||
DECLARE
|
||||
PROCEDURE require_column(p_table_name IN VARCHAR2, p_column_name IN VARCHAR2) IS
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_tab_columns
|
||||
WHERE owner = 'POC_2'
|
||||
AND table_name = p_table_name
|
||||
AND column_name = p_column_name;
|
||||
IF v_count = 0 THEN
|
||||
RAISE_APPLICATION_ERROR(-20001, 'Required column is missing: POC_2.' || p_table_name || '.' || p_column_name);
|
||||
END IF;
|
||||
END;
|
||||
|
||||
PROCEDURE require_runtime_object(p_object_name IN VARCHAR2, p_object_type IN VARCHAR2) IS
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_objects
|
||||
WHERE owner = 'ADMIN'
|
||||
AND object_name = p_object_name
|
||||
AND object_type = p_object_type
|
||||
AND status = 'VALID';
|
||||
IF v_count = 0 THEN
|
||||
RAISE_APPLICATION_ERROR(-20002, 'Required runtime object is invalid: ADMIN.' || p_object_name);
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
require_column('KB_STAKEHOLDERS', 'USER_ID');
|
||||
require_column('KB_STAKEHOLDERS', 'ROLE');
|
||||
require_column('KB_STAKEHOLDERS', 'CHANNEL');
|
||||
require_column('KB_CONTRACTS', 'CUST_ID');
|
||||
require_column('KB_CONTRACTS', 'CONTRACT_NO');
|
||||
require_column('KB_CONTRACTS', 'FC_ID');
|
||||
require_column('KB_CONTRACTS', 'FC_CHANNEL');
|
||||
require_column('KB_CUSTOMERS', 'CUST_ID');
|
||||
require_column('KB_CUSTOMERS', 'CUST_NM');
|
||||
require_column('KB_CUSTOMERS', 'RRN_MASKED');
|
||||
require_column('KB_COVERAGES', 'CUST_ID');
|
||||
require_column('KB_CLAIMS', 'CUST_ID');
|
||||
require_column('KB_CLAIMS', 'CLAIM_AMT');
|
||||
require_column('KB_CLAIMS', 'PAID_AMT');
|
||||
require_column('KB_EXTERNAL_HOLDINGS', 'CUST_ID');
|
||||
require_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER');
|
||||
require_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP');
|
||||
require_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE');
|
||||
|
||||
require_runtime_object('CB_AGENT_CTX_PKG', 'PACKAGE');
|
||||
require_runtime_object('CB_AGENT_CTX_PKG', 'PACKAGE BODY');
|
||||
require_runtime_object('CB_AGENT_DOC_VPD_FILTER', 'FUNCTION');
|
||||
require_runtime_object('CB_AGENT_CAN_READ_COLUMN', 'FUNCTION');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 2. Restoring CB_ORDS runtime grants ===
|
||||
GRANT SELECT ON POC_2.KB_STAKEHOLDERS TO CB_ORDS;
|
||||
GRANT SELECT ON POC_2.KB_CUSTOMERS TO CB_ORDS;
|
||||
GRANT SELECT ON POC_2.KB_PRODUCTS TO CB_ORDS;
|
||||
GRANT SELECT ON POC_2.KB_CONTRACTS TO CB_ORDS;
|
||||
GRANT SELECT ON POC_2.KB_COVERAGES TO CB_ORDS;
|
||||
GRANT SELECT ON POC_2.KB_CLAIMS TO CB_ORDS;
|
||||
GRANT SELECT ON POC_2.KB_EXTERNAL_HOLDINGS TO CB_ORDS;
|
||||
GRANT EXECUTE ON CB_AGENT_CTX_PKG TO CB_ORDS;
|
||||
GRANT EXECUTE ON CB_AGENT_DOC_VPD_FILTER TO CB_ORDS;
|
||||
GRANT EXECUTE ON CB_AGENT_CAN_READ_COLUMN TO CB_ORDS;
|
||||
|
||||
PROMPT === 3. Reapplying row VPD policies ===
|
||||
DECLARE
|
||||
PROCEDURE replace_row_policy(p_object_name IN VARCHAR2) IS
|
||||
v_count NUMBER;
|
||||
v_policy_name VARCHAR2(128) := 'KB_' || p_object_name || '_ROW_POLICY';
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_count
|
||||
FROM all_policies
|
||||
WHERE object_owner = 'POC_2'
|
||||
AND object_name = p_object_name
|
||||
AND policy_name = v_policy_name;
|
||||
IF v_count > 0 THEN
|
||||
DBMS_RLS.DROP_POLICY(
|
||||
object_schema => 'POC_2',
|
||||
object_name => p_object_name,
|
||||
policy_name => v_policy_name
|
||||
);
|
||||
END IF;
|
||||
DBMS_RLS.ADD_POLICY(
|
||||
object_schema => 'POC_2',
|
||||
object_name => p_object_name,
|
||||
policy_name => v_policy_name,
|
||||
function_schema => 'ADMIN',
|
||||
policy_function => 'CB_AGENT_DOC_VPD_FILTER',
|
||||
statement_types => 'SELECT',
|
||||
policy_type => DBMS_RLS.DYNAMIC,
|
||||
enable => TRUE
|
||||
);
|
||||
END;
|
||||
BEGIN
|
||||
replace_row_policy('KB_STAKEHOLDERS');
|
||||
replace_row_policy('KB_CUSTOMERS');
|
||||
replace_row_policy('KB_PRODUCTS');
|
||||
replace_row_policy('KB_CONTRACTS');
|
||||
replace_row_policy('KB_COVERAGES');
|
||||
replace_row_policy('KB_CLAIMS');
|
||||
replace_row_policy('KB_EXTERNAL_HOLDINGS');
|
||||
END;
|
||||
/
|
||||
|
||||
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
|
||||
PROCEDURE drop_column_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
DBMS_RLS.DROP_POLICY(
|
||||
object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name
|
||||
);
|
||||
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;
|
||||
BEGIN
|
||||
drop_column_policy('KB_CUSTOMERS', 'KB_CUST_NM_CLS_POLICY');
|
||||
drop_column_policy('KB_CUSTOMERS', 'KB_RRN_CLS_POLICY');
|
||||
drop_column_policy('KB_CLAIMS', 'KB_CLAIM_AMT_CLS_POLICY');
|
||||
drop_column_policy('KB_CLAIMS', 'KB_PAID_AMT_CLS_POLICY');
|
||||
drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_INSURER_CLS_POLICY');
|
||||
drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_GRP_CLS_POLICY');
|
||||
drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY');
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === 5. Verification ===
|
||||
SELECT object_name,
|
||||
COUNT(*) AS policy_count,
|
||||
LISTAGG(policy_name, ', ') WITHIN GROUP (ORDER BY policy_name) AS policy_names
|
||||
FROM all_policies
|
||||
WHERE object_owner = 'POC_2'
|
||||
AND object_name IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
GROUP BY object_name
|
||||
ORDER BY object_name;
|
||||
|
||||
SELECT table_name, grantee, privilege
|
||||
FROM all_tab_privs
|
||||
WHERE table_schema = 'POC_2'
|
||||
AND grantee = 'CB_ORDS'
|
||||
AND privilege = 'SELECT'
|
||||
AND table_name IN (
|
||||
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
|
||||
)
|
||||
ORDER BY table_name;
|
||||
|
||||
PROMPT === KB VPD/CLS restoration complete ===
|
||||
EXIT
|
||||
104
database/adb/56_kb_select_ai_router_team.sql
Normal file
104
database/adb/56_kb_select_ai_router_team.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- ============================================================
|
||||
-- 56_kb_select_ai_router_team.sql
|
||||
--
|
||||
-- KB Select AI profile을 호출하는 최소 Agent / Tool / Task / Team 체인.
|
||||
-- Tool -> Task -> Router Agent -> Team
|
||||
-- Execute as POC_2 using SQLcl.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET DEFINE OFF
|
||||
SET FEEDBACK ON
|
||||
|
||||
PROMPT === Resetting only this Select AI router chain ===
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI_AGENT.DROP_TEAM('KB_SELECT_AI_ROUTER_TEAM', force => TRUE);
|
||||
DBMS_CLOUD_AI_AGENT.DROP_TASK('KB_SELECT_AI_ROUTER_TASK', force => TRUE);
|
||||
DBMS_CLOUD_AI_AGENT.DROP_AGENT('KB_SELECT_AI_ROUTER_AGENT', force => TRUE);
|
||||
DBMS_CLOUD_AI_AGENT.DROP_TOOL('KB_SELECT_AI_ROUTER_TOOL', force => TRUE);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Creating Select AI SQL tool ===
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
|
||||
tool_name => 'KB_SELECT_AI_ROUTER_TOOL',
|
||||
attributes => q'~{
|
||||
"tool_type": "SQL",
|
||||
"tool_params": {
|
||||
"profile_name": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2"
|
||||
},
|
||||
"instruction": "Use the configured Select AI profile for structured KB insurance questions. For this demo, generate SHOWSQL only: create a read-only SELECT or WITH statement, do not execute it, and never generate DDL, DML, transaction control, package calls, or data changes."
|
||||
}~',
|
||||
status => 'ENABLED',
|
||||
description => 'Calls KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2 to generate read-only KB SQL.'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Creating router agent ===
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI_AGENT.CREATE_AGENT(
|
||||
agent_name => 'KB_SELECT_AI_ROUTER_AGENT',
|
||||
attributes => q'~{
|
||||
"profile_name": "KB_AIDP_AGENT_GPT55_OCI_PROFILE_V2",
|
||||
"role": "You are the KB Select AI routing agent. Classify every user request. If it asks for KB structured insurance data, metrics, counts, contracts, claims, customers, products, coverage, holdings, or stakeholders, call the assigned KB Select AI SQL tool. Return a concise Korean answer and the generated SQL. Do not invent data, do not expose internal reasoning, and do not use any unassigned tool.",
|
||||
"enable_human_tool": false
|
||||
}~',
|
||||
status => 'ENABLED',
|
||||
description => 'Routes KB structured-data questions to the Select AI SQL tool.'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Registering tool task ===
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI_AGENT.CREATE_TASK(
|
||||
task_name => 'KB_SELECT_AI_ROUTER_TASK',
|
||||
attributes => q'~{
|
||||
"instruction": "Route this user request to the KB Select AI SQL tool when it requires structured insurance data: {query}. Use SHOWSQL only. Explain briefly in Korean what the generated SQL will retrieve. If the request is outside the KB structured-data scope, state that this team only handles KB structured-data SQL generation.",
|
||||
"tools": ["KB_SELECT_AI_ROUTER_TOOL"],
|
||||
"enable_human_tool": false
|
||||
}~',
|
||||
status => 'ENABLED',
|
||||
description => 'Single Select AI SQL generation task for KB structured-data requests.'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Creating agent task team ===
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI_AGENT.CREATE_TEAM(
|
||||
team_name => 'KB_SELECT_AI_ROUTER_TEAM',
|
||||
attributes => q'~{
|
||||
"agents": [
|
||||
{
|
||||
"name": "KB_SELECT_AI_ROUTER_AGENT",
|
||||
"task": "KB_SELECT_AI_ROUTER_TASK"
|
||||
}
|
||||
],
|
||||
"process": "sequential"
|
||||
}~',
|
||||
status => 'ENABLED',
|
||||
description => 'KB Select AI router Team: SQL tool, task, agent, and team.'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Registered chain ===
|
||||
SELECT tool_name, status
|
||||
FROM USER_AI_AGENT_TOOLS
|
||||
WHERE tool_name = 'KB_SELECT_AI_ROUTER_TOOL';
|
||||
|
||||
SELECT agent_name, status
|
||||
FROM USER_AI_AGENTS
|
||||
WHERE agent_name = 'KB_SELECT_AI_ROUTER_AGENT';
|
||||
|
||||
SELECT task_name, status
|
||||
FROM USER_AI_AGENT_TASKS
|
||||
WHERE task_name = 'KB_SELECT_AI_ROUTER_TASK';
|
||||
|
||||
SELECT agent_team_name, status
|
||||
FROM USER_AI_AGENT_TEAMS
|
||||
WHERE agent_team_name = 'KB_SELECT_AI_ROUTER_TEAM';
|
||||
|
||||
EXIT
|
||||
17
database/adb/57_kb_select_ai_router_team_test.sql
Normal file
17
database/adb/57_kb_select_ai_router_team_test.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Execute as POC_2 using SQLcl after 56.
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET PAGESIZE 100
|
||||
SET LINESIZE 240
|
||||
SET LONG 30000
|
||||
SET LONGCHUNKSIZE 30000
|
||||
SET FEEDBACK ON
|
||||
|
||||
SELECT DBMS_CLOUD_AI_AGENT.RUN_TEAM(
|
||||
team_name => 'KB_SELECT_AI_ROUTER_TEAM',
|
||||
user_prompt => 'KB 고객 원장의 전체 고객 수를 계산하는 SQL을 만들어 줘. SQL만 생성하고 실행하지 마.',
|
||||
params => '{"conversation_id":"kb-select-ai-router-' ||
|
||||
TO_CHAR(SYSTIMESTAMP, 'YYYYMMDDHH24MISSFF3') || '"}'
|
||||
) AS agent_response
|
||||
FROM dual;
|
||||
|
||||
EXIT
|
||||
57
database/adb/58_kb_select_ai_router_api.sql
Normal file
57
database/adb/58_kb_select_ai_router_api.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- ============================================================
|
||||
-- 58_kb_select_ai_router_api.sql
|
||||
--
|
||||
-- POC_2 소유 Select AI Team을 ORDS runtime(CB_ORDS)에서 안전하게
|
||||
-- 호출하기 위한 definer-rights API.
|
||||
--
|
||||
-- Execute as POC_2 using SQLcl after 56_kb_select_ai_router_team.sql.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === Creating POC_2 Select AI Team API ===
|
||||
CREATE OR REPLACE PACKAGE kb_select_ai_router_api AUTHID DEFINER AS
|
||||
FUNCTION run_team(
|
||||
p_prompt IN CLOB,
|
||||
p_conversation_id IN VARCHAR2 DEFAULT NULL
|
||||
) RETURN CLOB;
|
||||
END kb_select_ai_router_api;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE PACKAGE BODY kb_select_ai_router_api AS
|
||||
FUNCTION run_team(
|
||||
p_prompt IN CLOB,
|
||||
p_conversation_id IN VARCHAR2 DEFAULT NULL
|
||||
) RETURN CLOB AS
|
||||
v_params CLOB;
|
||||
v_conversation_id VARCHAR2(128) := TRIM(p_conversation_id);
|
||||
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 v_conversation_id IS NOT NULL THEN
|
||||
IF NOT REGEXP_LIKE(v_conversation_id, '^[A-Za-z0-9._:-]{1,128}$') THEN
|
||||
RAISE_APPLICATION_ERROR(-20802, 'conversationId contains unsupported characters');
|
||||
END IF;
|
||||
v_params := '{"conversation_id":"' || v_conversation_id || '"}';
|
||||
END IF;
|
||||
|
||||
RETURN DBMS_CLOUD_AI_AGENT.RUN_TEAM(
|
||||
team_name => 'KB_SELECT_AI_ROUTER_TEAM',
|
||||
user_prompt => p_prompt,
|
||||
params => v_params
|
||||
);
|
||||
END run_team;
|
||||
END kb_select_ai_router_api;
|
||||
/
|
||||
|
||||
SHOW ERRORS
|
||||
|
||||
PROMPT === Granting only the Team API to the ORDS runtime ===
|
||||
GRANT EXECUTE ON kb_select_ai_router_api TO cb_ords;
|
||||
|
||||
PROMPT === POC_2 Select AI Team API ready ===
|
||||
EXIT
|
||||
213
database/adb/59_kb_select_ai_router_ords.sql
Normal file
213
database/adb/59_kb_select_ai_router_ords.sql
Normal file
@@ -0,0 +1,213 @@
|
||||
-- ============================================================
|
||||
-- 59_kb_select_ai_router_ords.sql
|
||||
--
|
||||
-- POST /ords/cb-ords/kb-select-ai-agent/run
|
||||
-- Authorization: Bearer <VPD token>
|
||||
-- {"prompt":"...","conversationId":"optional-safe-id"}
|
||||
--
|
||||
-- The ORDS runtime validates the bearer and sets the application context
|
||||
-- before invoking the POC_2-owned Team API. The context is session scoped,
|
||||
-- so VPD still applies to any SQL action selected by the Select AI tool.
|
||||
--
|
||||
-- Execute as CB_ORDS after 22 and 58.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE OFF
|
||||
|
||||
PROMPT === Creating local ORDS Team API wrapper ===
|
||||
CREATE OR REPLACE PACKAGE cb_select_ai_ords_api AUTHID DEFINER AS
|
||||
FUNCTION run_team(
|
||||
p_prompt IN CLOB,
|
||||
p_conversation_id IN VARCHAR2
|
||||
) RETURN CLOB;
|
||||
END cb_select_ai_ords_api;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE PACKAGE BODY cb_select_ai_ords_api AS
|
||||
FUNCTION run_team(
|
||||
p_prompt IN CLOB,
|
||||
p_conversation_id IN VARCHAR2
|
||||
) RETURN CLOB AS
|
||||
BEGIN
|
||||
RETURN poc_2.kb_select_ai_router_api.run_team(
|
||||
p_prompt => p_prompt,
|
||||
p_conversation_id => p_conversation_id
|
||||
);
|
||||
END run_team;
|
||||
END cb_select_ai_ords_api;
|
||||
/
|
||||
|
||||
SHOW ERRORS
|
||||
|
||||
-- ORDS validates named PL/SQL references through its gateway account before
|
||||
-- dispatching to the CB_ORDS parsing schema. These are the only two local
|
||||
-- wrappers exposed to that account; both remain definer-rights and fixed in
|
||||
-- scope (Bearer context setup and one fixed Team invocation).
|
||||
GRANT EXECUTE ON cb_ords_handler_pkg TO ords_public_user;
|
||||
GRANT EXECUTE ON cb_select_ai_ords_api TO ords_public_user;
|
||||
|
||||
PROMPT === Resetting only the KB Select AI Agent ORDS module ===
|
||||
BEGIN
|
||||
ORDS.DELETE_MODULE(p_module_name => 'kb.select.ai.agent');
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN NULL;
|
||||
END;
|
||||
/
|
||||
|
||||
PROMPT === Creating KB Select AI Agent ORDS endpoint ===
|
||||
BEGIN
|
||||
ORDS.DEFINE_MODULE(
|
||||
p_module_name => 'kb.select.ai.agent',
|
||||
p_base_path => 'kb-select-ai-agent/',
|
||||
p_items_per_page => 0,
|
||||
p_status => 'PUBLISHED'
|
||||
);
|
||||
|
||||
ORDS.DEFINE_TEMPLATE(
|
||||
p_module_name => 'kb.select.ai.agent',
|
||||
p_pattern => 'run'
|
||||
);
|
||||
|
||||
ORDS.DEFINE_TEMPLATE(
|
||||
p_module_name => 'kb.select.ai.agent',
|
||||
p_pattern => 'health'
|
||||
);
|
||||
|
||||
ORDS.DEFINE_HANDLER(
|
||||
p_module_name => 'kb.select.ai.agent',
|
||||
p_pattern => 'health',
|
||||
p_method => 'GET',
|
||||
p_source_type => ORDS.source_type_plsql,
|
||||
p_source => q'~
|
||||
BEGIN
|
||||
:status_code := 200;
|
||||
OWA_UTIL.MIME_HEADER('application/json', FALSE);
|
||||
HTP.P('Cache-Control: no-store');
|
||||
OWA_UTIL.HTTP_HEADER_CLOSE;
|
||||
HTP.P('{"status":"ok","team":"KB_SELECT_AI_ROUTER_TEAM"}');
|
||||
END;
|
||||
~',
|
||||
p_items_per_page => 0
|
||||
);
|
||||
|
||||
ORDS.DEFINE_HANDLER(
|
||||
p_module_name => 'kb.select.ai.agent',
|
||||
p_pattern => 'run',
|
||||
p_method => 'POST',
|
||||
p_source_type => ORDS.source_type_plsql,
|
||||
p_source => q'~
|
||||
DECLARE
|
||||
v_body_text CLOB;
|
||||
v_prompt VARCHAR2(32767);
|
||||
v_requested_conversation_id VARCHAR2(128);
|
||||
v_internal_conversation_id VARCHAR2(128);
|
||||
v_stakeholder_user_id VARCHAR2(4000);
|
||||
v_answer CLOB;
|
||||
v_response CLOB;
|
||||
v_error_code NUMBER;
|
||||
v_error_message VARCHAR2(4000);
|
||||
BEGIN
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
|
||||
|
||||
-- ORDS exposes request JSON as a stream bind. Read it once only.
|
||||
v_body_text := :body_text;
|
||||
v_prompt := JSON_VALUE(v_body_text, '$.prompt' RETURNING VARCHAR2(32767));
|
||||
v_requested_conversation_id := JSON_VALUE(v_body_text, '$.conversationId' RETURNING VARCHAR2(128));
|
||||
|
||||
IF v_prompt IS NULL OR TRIM(v_prompt) IS NULL THEN
|
||||
RAISE_APPLICATION_ERROR(-20801, 'prompt is required');
|
||||
END IF;
|
||||
IF v_requested_conversation_id IS NULL THEN
|
||||
v_requested_conversation_id := 'kb-' || LOWER(RAWTOHEX(SYS_GUID()));
|
||||
ELSIF NOT REGEXP_LIKE(v_requested_conversation_id, '^[A-Za-z0-9._:-]{1,128}$') THEN
|
||||
RAISE_APPLICATION_ERROR(-20802, 'conversationId contains unsupported characters');
|
||||
END IF;
|
||||
|
||||
-- A connection-pooled ORDS session can be reused. Scope the Team's
|
||||
-- conversation memory to the VPD subject as well as the caller's public ID.
|
||||
v_stakeholder_user_id := NVL(SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'), 'anonymous');
|
||||
v_internal_conversation_id := 'kb-' || LOWER(RAWTOHEX(STANDARD_HASH(
|
||||
v_stakeholder_user_id || ':' || v_requested_conversation_id,
|
||||
'SHA256'
|
||||
)));
|
||||
|
||||
-- ORDS validates static PL/SQL references before executing a Handler.
|
||||
-- Invoke the local, fixed-scope Team wrapper through bound dynamic PL/SQL
|
||||
-- so the runtime parser only sees the trusted local context package.
|
||||
EXECUTE IMMEDIATE
|
||||
'BEGIN :result := cb_select_ai_ords_api.run_team(:prompt, :conversation_id); END;'
|
||||
USING OUT v_answer, IN v_prompt, IN v_internal_conversation_id;
|
||||
|
||||
SELECT JSON_OBJECT(
|
||||
'team' VALUE 'KB_SELECT_AI_ROUTER_TEAM',
|
||||
'conversationId' VALUE v_requested_conversation_id,
|
||||
'answer' VALUE v_answer
|
||||
RETURNING CLOB
|
||||
)
|
||||
INTO v_response
|
||||
FROM dual;
|
||||
|
||||
:status_code := 200;
|
||||
OWA_UTIL.MIME_HEADER('application/json', FALSE);
|
||||
HTP.P('Cache-Control: no-store');
|
||||
OWA_UTIL.HTTP_HEADER_CLOSE;
|
||||
HTP.P(v_response);
|
||||
|
||||
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 (-20801, -20802) OR v_error_code BETWEEN -40599 AND -40400 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 v_error_message
|
||||
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.agent',
|
||||
p_pattern => 'run',
|
||||
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.agent',
|
||||
p_pattern => 'run',
|
||||
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 === KB Select AI Agent ORDS endpoint ready ===
|
||||
PROMPT Path: /ords/cb-ords/kb-select-ai-agent/run
|
||||
EXIT
|
||||
198
database/adb/60_kb_vpd_admin_full_access_token.sql
Normal file
198
database/adb/60_kb_vpd_admin_full_access_token.sql
Normal file
@@ -0,0 +1,198 @@
|
||||
-- ============================================================
|
||||
-- 60_kb_vpd_admin_full_access_token.sql
|
||||
--
|
||||
-- Creates the KB_VPD_ADMIN application user and grants the explicit
|
||||
-- physical condition STATIC_SQL / 1 = 1 for every KB protected object.
|
||||
--
|
||||
-- Execute as ADMIN using SQLcl with one positional argument: the newly
|
||||
-- generated Bearer token. Do not print or commit that argument.
|
||||
-- ============================================================
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET ECHO OFF
|
||||
SET FEEDBACK ON
|
||||
SET DEFINE ON
|
||||
SET VERIFY OFF
|
||||
SET SERVEROUTPUT ON
|
||||
|
||||
DEFINE bearer_token = '&1'
|
||||
|
||||
DECLARE
|
||||
c_stakeholder_user_id CONSTANT VARCHAR2(30) := 'KB_VPD_ADMIN';
|
||||
c_role_name CONSTANT VARCHAR2(80) := 'KB_VPD_ADMIN_ROLE';
|
||||
c_token_description CONSTANT VARCHAR2(200) := 'KB VPD admin full-access token';
|
||||
|
||||
v_admin_role_id NUMBER;
|
||||
v_identity_role_id NUMBER;
|
||||
v_app_user_id NUMBER;
|
||||
v_permission_id NUMBER;
|
||||
v_count NUMBER;
|
||||
|
||||
PROCEDURE ensure_role(
|
||||
p_role_name IN VARCHAR2,
|
||||
p_level IN VARCHAR2,
|
||||
p_role_id OUT NUMBER
|
||||
) IS
|
||||
BEGIN
|
||||
SELECT role_id INTO p_role_id
|
||||
FROM cb_app_role
|
||||
WHERE role_name = p_role_name;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(role_id), 0) + 1 INTO p_role_id FROM cb_app_role;
|
||||
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
|
||||
VALUES (p_role_id, p_role_name, p_level);
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_user_role(p_user_id IN NUMBER, p_role_id IN NUMBER) IS
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM cb_user_role
|
||||
WHERE user_id = p_user_id AND role_id = p_role_id;
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_user_role(user_id, role_id) VALUES (p_user_id, p_role_id);
|
||||
END IF;
|
||||
END;
|
||||
|
||||
PROCEDURE ensure_full_access(p_target_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
SELECT MIN(perm_id) INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = v_admin_role_id
|
||||
AND target_name = p_target_name
|
||||
AND action_name = 'SELECT';
|
||||
|
||||
IF v_permission_id IS NULL THEN
|
||||
SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual;
|
||||
INSERT INTO cb_permission(
|
||||
perm_id, role_id, target_name, action_name, permission_effect
|
||||
) VALUES (
|
||||
v_permission_id, v_admin_role_id, p_target_name, 'SELECT', 'ALLOW'
|
||||
);
|
||||
ELSE
|
||||
UPDATE cb_permission
|
||||
SET permission_effect = 'ALLOW'
|
||||
WHERE perm_id = v_permission_id;
|
||||
END IF;
|
||||
|
||||
DELETE FROM cb_permission_rule WHERE perm_id = v_permission_id;
|
||||
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||
VALUES (
|
||||
cb_permission_rule_seq.NEXTVAL, v_permission_id, NULL, 'STATIC_SQL', '1 = 1'
|
||||
);
|
||||
|
||||
EXECUTE IMMEDIATE
|
||||
'GRANT SELECT ON POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_target_name) || ' TO CB_ORDS';
|
||||
END;
|
||||
|
||||
PROCEDURE allow_original_column(p_target_name IN VARCHAR2, p_column_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
SELECT MIN(perm_id) INTO v_permission_id
|
||||
FROM cb_permission
|
||||
WHERE role_id = v_admin_role_id
|
||||
AND target_name = p_target_name
|
||||
AND action_name = 'SELECT';
|
||||
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM cb_permission_column
|
||||
WHERE permission_id = v_permission_id
|
||||
AND column_name = p_column_name;
|
||||
IF v_count = 0 THEN
|
||||
INSERT INTO cb_permission_column(permission_id, column_name)
|
||||
VALUES (v_permission_id, p_column_name);
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
MERGE INTO poc_2.kb_stakeholders target
|
||||
USING (
|
||||
SELECT c_stakeholder_user_id AS user_id,
|
||||
'VPD 관리자' AS user_nm,
|
||||
'관리자' AS role,
|
||||
'관리자채널' AS channel,
|
||||
'전체 KB 원장 및 민감 컬럼 접근' AS access_scope,
|
||||
'모든 보호 객체에 STATIC_SQL / 1 = 1 적용' AS rls_filter
|
||||
FROM dual
|
||||
) source
|
||||
ON (target.user_id = source.user_id)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
target.user_nm = source.user_nm,
|
||||
target.role = source.role,
|
||||
target.channel = source.channel,
|
||||
target.access_scope = source.access_scope,
|
||||
target.rls_filter = source.rls_filter
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
user_id, user_nm, role, channel, access_scope, rls_filter
|
||||
) VALUES (
|
||||
source.user_id, source.user_nm, source.role, source.channel,
|
||||
source.access_scope, source.rls_filter
|
||||
);
|
||||
|
||||
BEGIN
|
||||
SELECT user_id INTO v_app_user_id
|
||||
FROM cb_app_user
|
||||
WHERE stakeholder_user_id = c_stakeholder_user_id;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
SELECT NVL(MAX(user_id), 0) + 1 INTO v_app_user_id FROM cb_app_user;
|
||||
INSERT INTO cb_app_user(
|
||||
user_id, user_name, employee_no, dept_code,
|
||||
stakeholder_user_id, can_read_contents, active
|
||||
) VALUES (
|
||||
v_app_user_id, 'VPD 관리자', c_stakeholder_user_id, '관리자채널',
|
||||
c_stakeholder_user_id, 'Y', 'Y'
|
||||
);
|
||||
END;
|
||||
|
||||
UPDATE cb_app_user
|
||||
SET user_name = 'VPD 관리자',
|
||||
employee_no = c_stakeholder_user_id,
|
||||
dept_code = '관리자채널',
|
||||
can_read_contents = 'Y',
|
||||
active = 'Y'
|
||||
WHERE user_id = v_app_user_id;
|
||||
|
||||
ensure_role(c_role_name, 'RESTRICTED', v_admin_role_id);
|
||||
ensure_role('KB_STAKEHOLDER_IDENTITY_ROLE', 'INTERNAL', v_identity_role_id);
|
||||
ensure_user_role(v_app_user_id, v_admin_role_id);
|
||||
ensure_user_role(v_app_user_id, v_identity_role_id);
|
||||
|
||||
FOR object_name IN (
|
||||
SELECT column_value
|
||||
FROM TABLE(sys.odcivarchar2list(
|
||||
'KB_STAKEHOLDERS', 'KB_CLAIMS', 'KB_CONTRACTS', 'KB_COVERAGES',
|
||||
'KB_CUSTOMERS', 'KB_EXTERNAL_HOLDINGS', 'KB_PRODUCTS'
|
||||
))
|
||||
) LOOP
|
||||
ensure_full_access(object_name.column_value);
|
||||
END LOOP;
|
||||
|
||||
allow_original_column('KB_CUSTOMERS', 'CUST_NM');
|
||||
allow_original_column('KB_CUSTOMERS', 'RRN_MASKED');
|
||||
allow_original_column('KB_CLAIMS', 'CLAIM_AMT');
|
||||
allow_original_column('KB_CLAIMS', 'PAID_AMT');
|
||||
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER');
|
||||
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP');
|
||||
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE');
|
||||
|
||||
UPDATE cb_agent_bearer_key
|
||||
SET active = 'N', revoked_at = SYSDATE
|
||||
WHERE stakeholder_user_id = c_stakeholder_user_id
|
||||
AND active = 'Y';
|
||||
|
||||
INSERT INTO cb_agent_bearer_key(
|
||||
key_id, user_id, stakeholder_user_id, key_prefix, key_hash,
|
||||
issued_at, expires_at, revoked_at, active, description
|
||||
) VALUES (
|
||||
cb_agent_bearer_key_seq.NEXTVAL, v_app_user_id, c_stakeholder_user_id,
|
||||
'kbvpdadm', STANDARD_HASH('&&bearer_token', 'SHA256'),
|
||||
SYSDATE, ADD_MONTHS(SYSDATE, 12), NULL, 'Y', c_token_description
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
DBMS_OUTPUT.PUT_LINE('STAKEHOLDER_USER_ID=' || c_stakeholder_user_id);
|
||||
DBMS_OUTPUT.PUT_LINE('ROLE_NAME=' || c_role_name);
|
||||
DBMS_OUTPUT.PUT_LINE('OBJECT_PERMISSION_COUNT=7');
|
||||
END;
|
||||
/
|
||||
|
||||
UNDEFINE bearer_token
|
||||
EXIT
|
||||
100
database/adb/62_kb_aso_masking_backoffice_metadata.sql
Normal file
100
database/adb/62_kb_aso_masking_backoffice_metadata.sql
Normal 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
|
||||
679
database/adb/63_kb_aso_masking_rule_runtime.sql
Normal file
679
database/adb/63_kb_aso_masking_rule_runtime.sql
Normal 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
|
||||
54
database/adb/64_kb_aso_masking_default_column_rules.sql
Normal file
54
database/adb/64_kb_aso_masking_default_column_rules.sql
Normal 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
|
||||
221
database/adb/65_kb_select_ai_vpd_query_api.sql
Normal file
221
database/adb/65_kb_select_ai_vpd_query_api.sql
Normal 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user