Compare commits

99 Commits

Author SHA1 Message Date
devmrko
9ce0341102 refs #749: compare HMM Select AI annotation profiles 2026-08-11 11:01:25 +09:00
devmrko
2d0b5a3c1e refs #732: polish portal report question samples 2026-08-11 10:38:07 +09:00
devmrko
cbd49b6b05 refs #732: scope report titles to selected user 2026-08-10 18:02:49 +09:00
devmrko
84029ed633 refs #732: stabilize carrier report result schema 2026-08-10 17:37:32 +09:00
devmrko
6394bbf078 refs #740: enforce carrier VPD by MCP user 2026-08-10 17:26:01 +09:00
devmrko
2f5fc2bfbd refs #732: separate report data and rendering requests 2026-08-10 16:15:47 +09:00
devmrko
66fcb5d149 refs #732: polish HMM report title and response 2026-08-10 16:06:17 +09:00
devmrko
b13b913939 refs #732: preserve Select AI results in HMM reports 2026-08-10 15:51:29 +09:00
devmrko
c0c5a36ba0 refs #732: add dynamic HMM HTML report MCP tool 2026-08-10 15:23:36 +09:00
devmrko
cc2c3e3e25 refs #744: record delegated authentication capability blocker 2026-08-05 15:07:12 +09:00
devmrko
c519435266 refs #744: document verified AD Bridge sync recovery 2026-08-05 14:50:14 +09:00
devmrko
ad1e4573a9 refs #744: record AD Bridge installer execution constraints 2026-08-05 14:01:11 +09:00
devmrko
2bd373bcb3 refs #744: document AD Bridge installer artifact 2026-08-05 13:53:16 +09:00
devmrko
7d53b5c75c refs #744: document WinRM HTTPS access diagnosis 2026-08-05 13:29:25 +09:00
devmrko
fbf4c19c72 refs #744: standardize overview and cookbook documentation 2026-08-05 12:55:05 +09:00
devmrko
1c8ad8647f refs #744: document AD Bridge remote execution blocker 2026-08-05 11:08:56 +09:00
devmrko
90c771af57 refs #744: add OCI AD Bridge migration runbook 2026-08-05 11:06:39 +09:00
devmrko
de0956ef03 refs #744: clarify AD login source 2026-08-04 17:01:41 +09:00
devmrko
2c790f76b7 refs #744: document AD test user credentials 2026-08-04 16:54:35 +09:00
devmrko
91c96d7919 refs #744: expand AD OIDC DDS architecture guide 2026-08-04 16:49:33 +09:00
devmrko
89a6f4e4bc refs #744: document AIPF deny behavior 2026-08-04 16:44:52 +09:00
devmrko
e6432fa943 refs #744: document OAuth secret handling and test data 2026-08-04 16:33:41 +09:00
devmrko
cbc645466b refs #744: configure OCI IAM DDS context test 2026-08-04 16:18:22 +09:00
devmrko
35782b8a50 refs #744: document AIPF OAuth MCP test flow 2026-08-04 15:49:36 +09:00
devmrko
7dd47d39b9 refs #744: expose DDS MCP under public context path 2026-08-04 14:55:24 +09:00
devmrko
d868d7bbb8 refs #744: add AD OIDC MCP identity bridge 2026-08-04 14:24:07 +09:00
devmrko
9f46205057 docs: add HMM MCP VPD operations guide 2026-08-03 13:32:59 +09:00
devmrko
e9d50e6a32 refs #741 #742: reorganize repository by application 2026-08-03 10:20:36 +09:00
devmrko
2e44ed0b97 refs #740: enforce HMM MCP VPD runtime boundary 2026-07-31 13:57:04 +09:00
devmrko
af6add5a44 refs #732: expose carrier federation portal scenarios 2026-07-24 15:48:28 +09:00
devmrko
9bed75f0c0 refs #730: map carrier access groups to RDS performance 2026-07-24 14:43:45 +09:00
devmrko
cdb2c37ae3 refs #730: add ADB PostgreSQL federation recording scripts 2026-07-24 14:18:19 +09:00
devmrko
65a2b27fcc refs #729: seed RDS carrier performance demo 2026-07-24 14:04:42 +09:00
devmrko
0d9028ef13 refs #723: externalize backoffice catalogs and MCP tools 2026-07-23 19:41:54 +09:00
devmrko
1917df09a2 refs #712: authenticate backoffice MCP user tokens 2026-07-23 14:45:18 +09:00
devmrko
ce4b418603 refs #709: replace portal query token with HttpOnly auth 2026-07-23 14:40:47 +09:00
devmrko
da80041bcb refs #707: enforce HMM leave team VPD 2026-07-23 13:36:21 +09:00
devmrko
a495e7a962 refs #699: align HMM portal audit experience 2026-07-23 12:51:40 +09:00
devmrko
9e3840a4d7 refs #702: externalize HMM structured data catalog 2026-07-23 10:43:54 +09:00
devmrko
eb5105fe72 refs #699: preserve HMM policy MCP evidence 2026-07-23 09:40:09 +09:00
devmrko
62c5fb518c refs #699: record HMM deployment verification 2026-07-22 20:16:57 +09:00
devmrko
d6a558e45a refs #699: honor MCP tool input schemas 2026-07-22 20:13:17 +09:00
devmrko
ee355a9282 refs #699: configure HMM demo users and OCI runtime 2026-07-22 16:33:11 +09:00
devmrko
536938895a refs #702: align HMM MCP contract and settings 2026-07-22 15:54:48 +09:00
devmrko
adfd50d1fd refs #702: isolate HMM backoffice branch and restore full menus 2026-07-22 15:11:39 +09:00
devmrko
572dcd9a67 feat(hmm): align backoffice identity administration (#702) 2026-07-22 13:50:46 +09:00
devmrko
64965c294b feat(backoffice): retain access policy with Smilegate tables 2026-07-22 13:44:09 +09:00
devmrko
0b55e5b16b fix(backoffice): remove legacy catalog from Smilegate home 2026-07-22 13:36:54 +09:00
devmrko
ea5a116203 feat(backoffice): add Smilegate identity administration 2026-07-22 13:29:47 +09:00
devmrko
56b9b511c1 fix(backoffice): retain admin as control plane login 2026-07-22 13:18:26 +09:00
devmrko
b5d3913de3 fix(backoffice): mark seeded tool passwords as bcrypt 2026-07-22 13:16:25 +09:00
devmrko
79ed5a158e feat(backoffice): authenticate Smilegate tool users from ADB 2026-07-22 13:11:50 +09:00
devmrko
323f746d8a refs #701: load console profile overrides from dotenv 2026-07-22 13:01:40 +09:00
devmrko
a2112e07dc refs #701: modularize HMM console profile and scenarios 2026-07-22 12:57:23 +09:00
devmrko
97ed5d38ab feat(poc4): rebrand demo for Smilegate data and AI 2026-07-22 12:38:10 +09:00
devmrko
c4e158f639 feat(poc4): route UI through HMM MCP 2026-07-22 12:35:29 +09:00
devmrko
8cd4fc6ade fix(poc4): enforce sidebar text contrast 2026-07-22 11:24:05 +09:00
devmrko
6bc39c68b8 fix(poc4): force light interactive controls 2026-07-22 11:22:21 +09:00
devmrko
aa99a7d672 fix(poc4): force light color scheme 2026-07-22 11:20:44 +09:00
devmrko
7befdd0148 fix(poc4): enforce dark text on light surfaces 2026-07-22 11:18:24 +09:00
devmrko
72b2e084ac fix(poc4): remove Streamlit header chrome 2026-07-22 11:14:42 +09:00
devmrko
8b86d58d95 refactor(poc4): modularize HMM interface 2026-07-22 11:01:59 +09:00
devmrko
8e44a59d0a fix(poc4): remove synthetic HMM logo block 2026-07-22 10:51:36 +09:00
devmrko
f84ec7c7a0 fix(poc4): remove duplicate header wordmark 2026-07-22 10:47:35 +09:00
devmrko
e45956c7c6 style(poc4): simplify login and architecture screens 2026-07-22 10:45:41 +09:00
devmrko
d8c4482dc4 style(poc4): use minimal HMM interface 2026-07-22 10:39:33 +09:00
devmrko
add62a5b40 style(poc4): remove legacy hero decoration 2026-07-22 10:37:26 +09:00
devmrko
12d6d8f013 style(poc4): simplify HMM visual theme 2026-07-22 10:35:44 +09:00
devmrko
50b23aa4ed feat(poc4): add remembered portal login 2026-07-22 10:33:16 +09:00
devmrko
fe78b89452 fix(poc4): correct HMM wordmark 2026-07-22 10:29:39 +09:00
devmrko
ba0530cafb feat(poc4): refresh UI for HMM demo 2026-07-22 10:27:44 +09:00
devmrko
8eb8c566c6 Document source locations and git remotes 2026-07-22 09:57:00 +09:00
devmrko
f860f0279c [Architect] #683 document VPD read-only session diagnostics 2026-07-19 09:08:57 +09:00
devmrko
a90da6c7ff [Architect] #617 record current DDS MCP status 2026-07-19 09:05:39 +09:00
devmrko
c2e6ff2cc9 Clarify PoC4 AI Console audit document title 2026-07-14 14:33:27 +09:00
devmrko
7968ae73a0 Rename PoC4 MCP AI Console audit design doc 2026-07-14 14:33:13 +09:00
devmrko
1f6d97440f Document Oracle FGA audit verification 2026-07-14 14:26:50 +09:00
devmrko
a3ba76f2f4 Reverse document PoC4 MCP AI Console snapshot 2026-07-14 14:19:10 +09:00
devmrko
33399f93f5 Fix PoC4 MCP source import Redmine traceability 2026-07-14 14:14:08 +09:00
devmrko
5bdd24299e Import PoC4 MCP test UI source snapshot 2026-07-14 14:09:31 +09:00
devmrko
b48d9a0792 Update UX review traceability after deployment 2026-07-14 13:56:10 +09:00
devmrko
a40102a79a Improve data access control UX guidance 2026-07-13 23:14:51 +09:00
devmrko
e18b30feab Consolidate data access control backoffice updates 2026-07-13 23:07:09 +09:00
devmrko
403298d474 [DBA] add KB VPD admin full access condition 2026-07-08 13:02:11 +09:00
devmrko
fbeb18ccec [Developer] align Select AI ORDS runtime route 2026-07-08 10:37:51 +09:00
devmrko
61f7e4c775 [Developer] add KB Select AI Team ORDS MCP tool 2026-07-08 06:43:29 +09:00
devmrko
1cb8052045 [DBA] restore KB VPD after table reload 2026-07-07 18:47:10 +09:00
devmrko
f3eddd692c [QA] add Korean RBAC MCP scenario log runner 2026-07-07 17:59:49 +09:00
devmrko
ac57932472 [Documenter] add Codex MCP token environment sample 2026-07-07 17:54:45 +09:00
devmrko
9d3e384203 [Developer] add Codex streamable MCP endpoint 2026-07-07 17:52:29 +09:00
devmrko
6921682bcd [Developer] report NULLified KB columns in MCP results 2026-07-07 17:06:49 +09:00
devmrko
2ef04e6ab2 [Developer] #619 focus POC on KB VPD permissions 2026-07-07 16:02:22 +09:00
devmrko
ef944a6758 [Developer] #619 map VPD rules to token context variables 2026-07-07 14:24:50 +09:00
devmrko
2cdc738eb9 [Developer] #619 map stakeholder tokens to permission rules 2026-07-07 14:03:40 +09:00
devmrko
66b57da356 [Ops] #546 configure HTTPS reverse proxy 2026-07-07 13:24:26 +09:00
devmrko
6b60757721 [Developer] #618 add structured data browser 2026-07-07 13:14:46 +09:00
devmrko
8e1e521a70 [Developer] #546 make VPD setup rerunnable on ADB 2026-07-07 13:14:31 +09:00
devmrko
43f6e6e1af [Documenter] #575 document dashboard help diagrams 2026-07-03 10:07:10 +09:00
devmrko
b77966abaf [Architect] #617 define runtime DDS authorization 2026-07-03 10:05:26 +09:00
573 changed files with 37645 additions and 1880 deletions

View File

@@ -26,12 +26,25 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}"
# --- (2b) Spring Boot 백오피스 --- # --- (2b) Spring Boot 백오피스 ---
export BACKOFFICE_ADMIN_USER="admin" export BACKOFFICE_ADMIN_USER="admin"
export BACKOFFICE_ADMIN_PASSWORD="admin" export BACKOFFICE_ADMIN_PASSWORD="admin"
# 운영에서는 최초 한 번 생성한 {bcrypt} 해시를 .env에 보관합니다. 이를 사용하면
# 지속 로그인 쿠키가 앱 재기동 후에도 유효합니다. 원문 비밀번호 값은 Git에 넣지 마세요.
export BACKOFFICE_ADMIN_PASSWORD_HASH=""
# 읽기 전용 조회 계정. 활성화하면 ROLE_VIEWER로 로그인 가능하며 POST/PUT/PATCH/DELETE는 차단됩니다.
export BACKOFFICE_GUEST_ENABLED="false"
export BACKOFFICE_GUEST_USER="guest"
export BACKOFFICE_GUEST_PASSWORD=""
export BACKOFFICE_GUEST_PASSWORD_HASH=""
# 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다. # 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다.
export BACKOFFICE_BIND_ADDRESS="0.0.0.0" export BACKOFFICE_BIND_ADDRESS="0.0.0.0"
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none" export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none"
export BACKOFFICE_REQUIRE_HTTPS="false" export BACKOFFICE_REQUIRE_HTTPS="false"
export BACKOFFICE_SESSION_COOKIE_SECURE="false" export BACKOFFICE_SESSION_COOKIE_SECURE="false"
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" export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로 # ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다. # 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_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD="" 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_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini
export BACKOFFICE_AI_API_KEY="" export BACKOFFICE_AI_API_KEY=""
export BACKOFFICE_AI_TIMEOUT_SECONDS="30" export BACKOFFICE_AI_TIMEOUT_SECONDS="30"
# provider=oci이면 API key를 환경 변수로 복제하지 않고 OCI config profile로 요청에
# 서명합니다. POC3_LLM_GPT55_OCI_*/OCI_GENAI_*가 있으면 아래 BACKOFFICE_* 값보다
# fallback으로 사용됩니다.
export BACKOFFICE_AI_OCI_CONFIG_FILE=""
export BACKOFFICE_AI_OCI_PROFILE="DEFAULT"
export BACKOFFICE_AI_OCI_REGION=""
export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
# 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다. # 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다.
export VPDTEST1_API_KEY="" export VPDTEST1_API_KEY=""
export VPDTEST2_API_KEY="" export VPDTEST2_API_KEY=""
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) --- # --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) ---
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합. # ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
# 4명의 데모 유저: # 4명의 데모 유저:
# vpduser_my → MySQL view 만 SELECT 가능 # vpduser_my → MySQL view 만 SELECT 가능
@@ -63,7 +118,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26"
export VPDUSER_NONE_PASSWORD="RowFilter#None26" export VPDUSER_NONE_PASSWORD="RowFilter#None26"
# --- (3b) OPTIONAL — DDS 변형 데모용 엔드유저 비밀번호 --- # --- (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 데모와 공존. # 동일한 4-user 매트릭스를 declarative Data Grants 로 재현. VPD 데모와 공존.
export DDSUSER_MY_PASSWORD="DdsGrant#My2026" export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026" export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
@@ -84,7 +139,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용 export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) --- # --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성. # database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴. # ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
export PG_PORT="5432" export PG_PORT="5432"

7
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# 환경/비밀 — 절대 commit 금지 # 환경/비밀 — 절대 commit 금지
.env .env
.runtime/
.env.* .env.*
!.env.example !.env.example
@@ -17,6 +18,12 @@ logs/
# Java / Maven # Java / Maven
target/ target/
# Python
__pycache__/
*.py[cod]
.venv/
data/
# Locally downloaded development tools (for example SQLcl) # Locally downloaded development tools (for example SQLcl)
.tools/ .tools/

28
AGENTS.md Normal file
View 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을 조건으로 사용하지 않는다.

View File

@@ -32,7 +32,7 @@ End-to-End 데모입니다.
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만 행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
`email` 컬럼은 항상 `NULL` 로 반환. `email` 컬럼은 항상 `NULL` 로 반환.
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`). * VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`).
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수. * DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
### 3. 두 가지 구현 — VPD 와 DDS ### 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) | 원본 데이터 | | 원격 | `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 - 연결 | `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 - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 | | ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
@@ -74,7 +110,7 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
## 빠른 시작 (One-Click) ## 빠른 시작 (One-Click)
```bash ```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 cd vpd-permission-poc
# 1) 환경값 채우기 # 1) 환경값 채우기
@@ -132,7 +168,7 @@ $EDITOR .env
## 데모 시나리오 — 2×2 source access matrix ## 데모 시나리오 — 2×2 source access matrix
`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스): `database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 | | DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|---|---|---|---|---| |---|---|---|---|---|
@@ -150,7 +186,7 @@ $EDITOR .env
(`1=0` predicate) — **deny by default**. (`1=0` predicate) — **deny by default**.
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음. * 누구든 원본 테이블 직접 접근 시도 (`@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 로 실패하는 것을 스푸핑, DBMS_RLS 변경, 매핑 테이블 SELECT) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을
보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다. 보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다.
@@ -162,7 +198,7 @@ $EDITOR .env
## DDS 설정 핵심 ## DDS 설정 핵심
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이 DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다. 순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
### 사전 조건 ### 사전 조건
@@ -222,10 +258,23 @@ CREATE DATA GRANT admin.alice_apac_grant
. .
├── run.sh # 원클릭 엔트리포인트 ├── run.sh # 원클릭 엔트리포인트
├── .env.example ├── .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 검증 헬퍼 ├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼
├── sql/ ├── database/
│ ├── source/ │ ├── source/
│ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows │ │ ├── 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 │ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows
│ └── adb/ │ └── adb/
│ ├── 00_cleanup.sql # 멱등 teardown │ ├── 00_cleanup.sql # 멱등 teardown

View 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

View 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 패키지와 실행 명령은 이 문서의 새 이름만
사용한다.

View File

@@ -0,0 +1 @@
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""

View 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)),
)

View 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()

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View 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,
)

View 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

View 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",
]

View 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",
]

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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=>({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;","\"":"&quot;"}[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&amp;auto=webp&amp;quality=80&amp;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>

View 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"
}
}

View 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 리포트로 보여줘"
}
]
}

View 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": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
}
}
]
}

View 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"
}
]
}

View 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
}
]
}

View 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일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
}
]
}

View File

@@ -0,0 +1,4 @@
-r requirements.txt
fastmcp==3.4.3
langgraph>=1.2,<2
oci>=2.180,<3

View File

@@ -0,0 +1,5 @@
openpyxl>=3.1,<4
oci>=2.180,<3
oracledb>=2,<4
pandas>=2,<3
streamlit>=1.35,<2

View 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()

View 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
)

View 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()

View File

@@ -80,7 +80,7 @@ COMMIT;
-- COMMIT; -- COMMIT;
-- --
-- The policy function vpd_region_filter handles CSV → IN-list -- 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 === PROMPT === Seed complete ===

View File

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

View File

@@ -46,6 +46,7 @@ CREATE TABLE cb_app_user (
user_name VARCHAR2(50) NOT NULL, user_name VARCHAR2(50) NOT NULL,
employee_no VARCHAR2(20) NOT NULL, employee_no VARCHAR2(20) NOT NULL,
dept_code 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, 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 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), perm_id NUMBER NOT NULL REFERENCES cb_permission(perm_id),
rule_column VARCHAR2(128), rule_column VARCHAR2(128),
rule_type VARCHAR2(30) NOT NULL, rule_type VARCHAR2(30) NOT NULL,
rule_value VARCHAR2(100) rule_value VARCHAR2(1000 CHAR)
); );
CREATE TABLE cb_permission_column ( CREATE TABLE cb_permission_column (
@@ -87,6 +88,7 @@ CREATE TABLE cb_permission_column (
CREATE TABLE cb_agent_bearer_key ( CREATE TABLE cb_agent_bearer_key (
key_id NUMBER PRIMARY KEY, key_id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id), user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id),
stakeholder_user_id VARCHAR2(30),
key_hash VARCHAR2(128) NOT NULL UNIQUE, key_hash VARCHAR2(128) NOT NULL UNIQUE,
key_prefix VARCHAR2(16), key_prefix VARCHAR2(16),
issued_at DATE DEFAULT SYSDATE NOT NULL, issued_at DATE DEFAULT SYSDATE NOT NULL,
@@ -96,9 +98,9 @@ CREATE TABLE cb_agent_bearer_key (
); );
PROMPT === 3. Seeding permissions === PROMPT === 3. Seeding permissions ===
INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', 'N', 'Y'); INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', NULL, 'N', 'Y');
INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', 'N', 'Y'); INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', NULL, 'N', 'Y');
INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', 'Y', '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 (10, 'HR_DEPT_ROLE', 'INTERNAL');
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_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 (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 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 (rule_id, perm_id, rule_column, rule_type, rule_value)
INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL); VALUES (1000, 100, NULL, 'MY_DEPT', 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 (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'); INSERT INTO cb_permission_column VALUES (300, 'CONTENTS');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
WHENEVER SQLERROR EXIT SQL.SQLCODE WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON SET ECHO ON
SET FEEDBACK ON SET FEEDBACK ON
ALTER SESSION DISABLE PARALLEL DML;
PROMPT === Adding optional description column to bearer keys === PROMPT === Adding optional description column to bearer keys ===
BEGIN BEGIN
@@ -120,14 +121,23 @@ EXCEPTION
END; END;
/ /
CREATE TABLE cb_protected_object ( BEGIN
object_id NUMBER PRIMARY KEY, EXECUTE IMMEDIATE '
owner VARCHAR2(128) NOT NULL, CREATE TABLE cb_protected_object (
object_name VARCHAR2(128) NOT NULL UNIQUE, object_id NUMBER PRIMARY KEY,
ords_path VARCHAR2(300) NOT NULL, owner VARCHAR2(128) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL, object_name VARCHAR2(128) NOT NULL UNIQUE,
description VARCHAR2(200) ords_path VARCHAR2(300) 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 BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))'; EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))';
@@ -139,16 +149,25 @@ EXCEPTION
END; END;
/ /
CREATE TABLE cb_protected_column ( BEGIN
column_id NUMBER PRIMARY KEY, EXECUTE IMMEDIATE '
object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id), CREATE TABLE cb_protected_column (
column_name VARCHAR2(128) NOT NULL, column_id NUMBER PRIMARY KEY,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL, object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id),
visible_role_id NUMBER, column_name VARCHAR2(128) NOT NULL,
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL, sensitive_yn CHAR(1) DEFAULT ''N'' CHECK (sensitive_yn IN (''Y'',''N'')) NOT NULL,
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL, visible_role_id NUMBER,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name) 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 BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL)'; EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL)';
@@ -177,23 +196,45 @@ WHERE sensitivity_level = 'PUBLIC'
AND redaction_method = 'NONE' AND redaction_method = 'NONE'
AND sensitive_yn = 'Y'; AND sensitive_yn = 'Y';
CREATE TABLE cb_permission_column ( -- Autonomous Database can use parallel DML. Finish the compatibility update
permission_id NUMBER NOT NULL, -- before merging the seed rows into the same table.
column_name VARCHAR2(128) NOT NULL, COMMIT;
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
);
CREATE TABLE cb_ords_probe_audit ( BEGIN
audit_id NUMBER PRIMARY KEY, EXECUTE IMMEDIATE '
event_type VARCHAR2(50) NOT NULL, CREATE TABLE cb_permission_column (
key_id NUMBER, permission_id NUMBER NOT NULL,
object_id NUMBER, column_name VARCHAR2(128) NOT NULL,
status VARCHAR2(50), CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
row_count NUMBER, )';
error_code VARCHAR2(100), EXCEPTION
message VARCHAR2(500), WHEN OTHERS THEN
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL IF SQLCODE != -955 THEN
); RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
event_type VARCHAR2(50) NOT NULL,
key_id NUMBER,
object_id NUMBER,
status VARCHAR2(50),
row_count NUMBER,
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 BEGIN
EXECUTE IMMEDIATE ' EXECUTE IMMEDIATE '

View 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 ===

View File

@@ -3,7 +3,7 @@
-- Unit-style checks for cb_agent_doc_vpd_filter. -- Unit-style checks for cb_agent_doc_vpd_filter.
-- --
-- Run after: -- 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 WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF 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) 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'); 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 (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 (456102, 'RULE_TEST_SELF');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456103, 'RULE_TEST_ALL'); 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 (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 (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 (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 (456101, 456101);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102); 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 (456110, 456110);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456111, 456111); 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 (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) INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); 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) INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456113, 456112, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'DENY'); 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) INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456101, 456101, NULL, 'MY_DEPT', NULL); 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) INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456113, 456113, 'DEPT_CODE', '=', 'HR'); 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; COMMIT;
END; END;
/ /
@@ -260,6 +285,8 @@ BEGIN
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT', 456111, 'AND NOT'); 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_contains('ALLOW_ALL_MINUS_DENY_DEPT_DETAIL', 456111, 'TO_CHAR(DEPT_CODE) = ''HR''');
assert_equals('DENY_ONLY_DENIES', 456112, '1 = 0'); 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; cb_agent_ctx_pkg.clear_user;
END; END;
/ /

View File

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

View File

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

View File

@@ -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 ===

View 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;

View 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 ===

View 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 ===

View 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 ===

View 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 ===

View 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 ===

View 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 ===

View 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 ===

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

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