Compare commits

136 Commits

Author SHA1 Message Date
devmrko
14ac162a85 refs #745: add OCI HMM architecture diagrams 2026-08-04 14:37:38 +09:00
devmrko
8817b24043 docs: add generic MCP VPD operations guide 2026-08-03 13:28:53 +09:00
devmrko
750bfbab5b refs #743: validate advertised ADB MCP tools at startup 2026-08-03 11:07:15 +09:00
devmrko
22c0b571e2 refs #742: merge HMM application layout into main
# Conflicts:
#	src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java
#	vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java
#	vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java
#	vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml
#	vpd-backoffice/src/main/resources/templates/schema-metadata.html
#	vpd-backoffice/src/main/resources/templates/structured-data.html
2026-08-03 10:38:25 +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
20c6a82338 fix(backoffice): persist login until logout 2026-07-22 14:29:42 +09:00
devmrko
b3f2422b63 fix(backoffice): remove KB copy from game data screens 2026-07-22 14:28:41 +09:00
devmrko
1538aadb14 feat(backoffice): migrate remaining menus to Smilegate tables 2026-07-22 14:18:54 +09:00
devmrko
404244534a fix(backoffice): restore Smilegate group and role mappers 2026-07-22 14:13:23 +09:00
devmrko
ea4fc1de8b fix(backoffice): restore Smilegate user mapper 2026-07-22 14:11:23 +09:00
devmrko
0eb68664f1 fix(backoffice): restore operational navigation 2026-07-22 14:08:24 +09:00
devmrko
4eec33b2ed feat(backoffice): migrate masking rules to Smilegate tables 2026-07-22 14:05:33 +09:00
devmrko
b4ed8649f1 feat(backoffice): replace KB structured data with Smilegate games 2026-07-22 14:01:39 +09:00
devmrko
f7295277f1 fix(backoffice): remove legacy KB navigation 2026-07-22 13:56:14 +09:00
devmrko
075eb46ef8 fix(backoffice): support VPD policy notes on Smilegate schema 2026-07-22 13:53:59 +09:00
devmrko
2b66e27bfb feat(backoffice): grant full game data access to PoC users 2026-07-22 13:52:16 +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
devmrko
ef1331be4b [Developer] #617 apply DDS MCP end-user authorization 2026-07-02 09:34:58 +09:00
devmrko
fd09622c82 [Architect] #617 define IAM service identity boundary 2026-07-01 20:46:05 +09:00
devmrko
b7100cc62a [Architect] #617 design DDS MCP end-user context 2026-07-01 20:34:48 +09:00
devmrko
ebcce66bf7 [Developer] #567 record actual VPD execution with FGA 2026-07-01 16:02:03 +09:00
devmrko
57d77d2df2 [Developer] #567 list latest SQL cursors without time cutoff 2026-07-01 15:14:27 +09:00
devmrko
b39968d43f [Developer] #567 show recent SQL cursor candidates 2026-07-01 15:11:25 +09:00
devmrko
d301e4dad1 [Developer] #567 filter recent SQL cursors in Java 2026-07-01 15:06:41 +09:00
devmrko
1c66b973a7 [Developer] #567 use backoffice account for SQL evidence 2026-07-01 14:59:22 +09:00
devmrko
064a5bc31d [UX] #577 clarify VPD flow and SQL evidence 2026-07-01 14:49:49 +09:00
devmrko
a00db691f9 [UX] #577 restore VPD navigation domains 2026-07-01 14:37:24 +09:00
devmrko
07ce6c038a [Developer] #567 clarify SQL evidence privilege status 2026-07-01 14:02:36 +09:00
devmrko
bf393bffa5 [Developer] #567 add VPD SQL execution evidence 2026-07-01 13:47:30 +09:00
devmrko
07c090f6b0 feat: show DDS SQL execution evidence 2026-07-01 13:39:38 +09:00
devmrko
f15b453aad [UX] #577 simplify VPD product forms 2026-07-01 10:35:21 +09:00
devmrko
699909212f ui: simplify DDS default screens 2026-07-01 10:08:07 +09:00
devmrko
2a038662e0 ui: consolidate DDS navigation 2026-07-01 09:48:41 +09:00
devmrko
8830f07be1 ui: simplify DDS knowledge registration 2026-07-01 09:43:52 +09:00
devmrko
7067941ddd [UX] #577 keep VPD primary menu on the first row 2026-07-01 09:43:48 +09:00
devmrko
670918f56f [UX] #577 stack VPD primary navigation above submenus 2026-07-01 09:41:01 +09:00
devmrko
c52890e721 [UX] #577 show VPD submenu below primary navigation 2026-07-01 09:38:05 +09:00
devmrko
1218ae1719 [UX] #577 align VPD menus with DDS 2026-07-01 08:21:42 +09:00
devmrko
be14b4c797 [UX] #577 present VPD as a product console 2026-07-01 08:06:03 +09:00
devmrko
45621e1b7d ui: reshape DDS navigation as management console 2026-07-01 08:04:39 +09:00
devmrko
12b7e00cef ui: adopt product language for DDS console 2026-07-01 07:57:52 +09:00
devmrko
5ab6c53eaf [UX] #575 use task-focused dashboard copy 2026-06-30 21:11:39 +09:00
devmrko
b72e860ba2 ops: reduce DDS JVM heap 2026-06-30 20:58:57 +09:00
devmrko
3bd3933852 ops: cap DDS JVM memory 2026-06-30 20:57:53 +09:00
devmrko
22a7eda219 feat: record DDS protection evidence 2026-06-30 20:56:52 +09:00
devmrko
f162851bc6 [UX] #605 make permission wizard safety review actionable 2026-06-30 20:39:54 +09:00
devmrko
1766696ab4 feat: add DDS protection status dashboard 2026-06-30 20:31:31 +09:00
devmrko
2f67587b40 [UX] #599 separate database preparation from settings 2026-06-30 20:29:46 +09:00
devmrko
699a1ac127 docs: strengthen DDS protection review criteria 2026-06-30 20:13:09 +09:00
devmrko
eea7bf0974 docs: detail DDS protection review #581 2026-06-30 20:03:20 +09:00
devmrko
0babadee5f docs: link DDS page reviews to Redmine 2026-06-30 19:44:31 +09:00
devmrko
0ed048905a [UX] #577 focus effective access on selected user 2026-06-30 18:05:04 +09:00
devmrko
e7070663c3 docs: add DDS persona UX review 2026-06-30 17:53:03 +09:00
devmrko
28379c47f8 [Developer] #576 clarify verification user names 2026-06-30 17:41:11 +09:00
devmrko
8baaa3fe56 [Designer] #575 focus dashboard on primary actions 2026-06-30 17:31:29 +09:00
devmrko
d72dbbe842 ui: collapse DDS dashboard guidance 2026-06-30 16:42:56 +09:00
devmrko
e0fbf1e0c5 [Developer] #573 normalize vector access whitelist 2026-06-30 16:33:59 +09:00
devmrko
a9c58fa854 ui: remove source dataset selector from DDS flow 2026-06-30 14:40:43 +09:00
devmrko
780bbd60db [Developer] #572 separate vector search and VPD filter trace 2026-06-30 14:40:32 +09:00
devmrko
ef7a855462 docs: trace sales knowledge scenario 2026-06-30 14:29:58 +09:00
devmrko
fb93781925 [Developer] #571 show VPD context in SQL trace 2026-06-30 14:29:54 +09:00
devmrko
8978be4182 ui: label DDS source users as secondary 2026-06-30 14:26:07 +09:00
devmrko
04cca64f10 feat: center DDS demo on sales knowledge authorization 2026-06-30 14:21:39 +09:00
devmrko
188b398dcb [Developer] #570 show vector VPD effective SQL trace 2026-06-30 14:16:42 +09:00
devmrko
cde5266a77 [Developer] #569 plain text vector Top-K probe 2026-06-30 13:23:49 +09:00
devmrko
084c5e59cf docs: clarify DDS bearer context adapter boundary 2026-06-30 13:22:21 +09:00
627 changed files with 44328 additions and 3355 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,61 @@ 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 도구 호환 설정. 여러 Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
# AGENT_TOOL targetName은 서버 시작 시 USER_AI_AGENT_TOOLS의 ENABLED 상태를 검증합니다.
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 +119,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"
@@ -71,8 +127,20 @@ export DDSUSER_BOTH_PASSWORD="DdsGrant#Both26"
export DDSUSER_NONE_PASSWORD="DdsGrant#None26" export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26" export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
# --- (3c) DDS MCP SSE · OCI IAM Confidential Application ---
# 실제 값은 .env 또는 배포 환경의 secret store에만 둔다.
export DDS_OCI_IAM_CLIENT_ID=""
export DDS_OCI_IAM_CLIENT_SECRET=""
export DDS_OCI_IAM_DOMAIN_URL="" # 예: https://idcs-<unique>.identity.oraclecloud.com
export DDS_OCI_IAM_DATABASE_SCOPE="" # OCI IAM의 DB resource scope
export DDS_OCI_IAM_TOKEN_URI="" # 비우면 ${DDS_OCI_IAM_DOMAIN_URL}/oauth2/v1/token
export DDS_OCI_IAM_TIMEOUT_SECONDS="10s"
export DDS_OCI_IAM_REFRESH_SKEW_SECONDS="60s"
export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) --- # --- (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"

9
.gitignore vendored
View File

@@ -17,6 +17,15 @@ logs/
# Java / Maven # Java / Maven
target/ target/
# Python
__pycache__/
*.py[cod]
.venv/
data/
# Locally downloaded development tools (for example SQLcl)
.tools/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

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,48 @@
# AI Web Agent Console local/VM configuration example.
# Copy to .env and keep real credentials out of Git.
# MCP
HMM_MCP_BEARER_TOKEN=
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,57 @@
# 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` 같은 환경변수 이름만 참조한다.
## 검증
```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)

5904
ai-web-agent-console/app.py Normal file

File diff suppressed because it is too large Load Diff

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,62 @@
{
"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": "선사 실적 Federation",
"title": "팀원별 담당 선사 최신 실적",
"question": "E1001 팀장의 팀원별 담당 선사와 해당 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
},
{
"id": "FED-02",
"enabled": true,
"category": "선사 실적 Federation",
"title": "위험 선사와 담당자",
"question": "E1001 팀에서 최신 위험 등급이 RED인 선사를 담당하는 직원, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
},
{
"id": "FED-03",
"enabled": true,
"category": "선사 실적 Federation",
"title": "담당 선사 월별 추이",
"question": "E1006 직원이 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
}
]
}

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,23 @@
{
"default_server_id": "hmm_hr_mcp",
"servers": [
{
"id": "hmm_hr_mcp",
"enabled": true,
"provider": "hmm_compat_mcp",
"transport": "http",
"endpoint_url": "https://hmm-mcp.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"
],
"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",
"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",
"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",
"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",
"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",
"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,238 @@
from __future__ import annotations
import json
from pathlib import Path
import tempfile
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-01", "FED-02", "FED-03"} & set(by_id),
)
self.assertTrue(
all("선사" in by_id[scenario_id].question for scenario_id in (
"FED-01", "FED-02", "FED-03"
))
)
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_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.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" 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, 'agent_hr', 'E10234', 'HR', 'N', 'Y'); INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', NULL, 'N', 'Y');
INSERT INTO cb_app_user VALUES (102, 'agent_fin_self', 'E2001', 'FIN', 'N', 'Y'); INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', NULL, 'N', 'Y');
INSERT INTO cb_app_user VALUES (103, 'agent_all', '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

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

View File

@@ -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
EXECUTE IMMEDIATE '
CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY, object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL, owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL UNIQUE, object_name VARCHAR2(128) NOT NULL UNIQUE,
ords_path VARCHAR2(300) NOT NULL, ords_path VARCHAR2(300) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL, enabled_yn CHAR(1) DEFAULT ''Y'' CHECK (enabled_yn IN (''Y'',''N'')) NOT NULL,
description VARCHAR2(200) 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
EXECUTE IMMEDIATE '
CREATE TABLE cb_protected_column (
column_id NUMBER PRIMARY KEY, column_id NUMBER PRIMARY KEY,
object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id), object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id),
column_name VARCHAR2(128) NOT NULL, column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL, sensitive_yn CHAR(1) DEFAULT ''N'' CHECK (sensitive_yn IN (''Y'',''N'')) NOT NULL,
visible_role_id NUMBER, visible_role_id NUMBER,
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL, sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL,
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL, redaction_method VARCHAR2(20) DEFAULT ''NONE'' NOT NULL,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name) 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,13 +196,28 @@ 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
-- before merging the seed rows into the same table.
COMMIT;
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_permission_column (
permission_id NUMBER NOT NULL, permission_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL, column_name VARCHAR2(128) NOT NULL,
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name) CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
); )';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
/
CREATE TABLE cb_ords_probe_audit ( BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY, audit_id NUMBER PRIMARY KEY,
event_type VARCHAR2(50) NOT NULL, event_type VARCHAR2(50) NOT NULL,
key_id NUMBER, key_id NUMBER,
@@ -193,7 +227,14 @@ CREATE TABLE cb_ords_probe_audit (
error_code VARCHAR2(100), error_code VARCHAR2(100),
message VARCHAR2(500), message VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL 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

@@ -53,14 +53,20 @@ DECLARE
v_vpd_predicate VARCHAR2(32767); v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767); v_effective_sql VARCHAR2(32767);
BEGIN BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header); cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
BEGIN BEGIN
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS'); v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS');
v_effective_sql := 'SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score ' v_effective_sql := 'SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score '
|| 'FROM (SELECT ... FROM admin.cb_vector_search_documents d ' || 'FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, '
|| 'WHERE d.embedding IS NOT NULL /* VPD: ' || v_vpd_predicate || ' */ ' || 'd.source_uri, d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score '
|| 'ORDER BY score) ranked_chunks ' || 'FROM admin.cb_vector_search_documents d '
|| 'WHERE d.embedding IS NOT NULL';
IF TRIM(v_vpd_predicate) <> '1 = 1' THEN
v_effective_sql := v_effective_sql || ' AND (' || v_vpd_predicate || ')';
END IF;
v_effective_sql := v_effective_sql
|| ' ORDER BY score) ranked_chunks '
|| 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)'; || 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)';
EXCEPTION EXCEPTION
WHEN OTHERS THEN WHEN OTHERS THEN
@@ -157,6 +163,17 @@ END;
p_access_method => 'IN' p_access_method => 'IN'
); );
ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.vector',
p_pattern => 'search',
p_method => 'POST',
p_name => 'X-VPD-Probe-Id',
p_bind_variable_name => 'probe_id',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER( ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.vector', p_module_name => 'cb.agent.vector',
p_pattern => 'search', p_pattern => 'search',

View File

@@ -78,19 +78,14 @@ CREATE OR REPLACE DATA GRANT admin.dds_demo_both_vector_grant
-- dds_demo_none_role intentionally receives no grant (default deny). -- dds_demo_none_role intentionally receives no grant (default deny).
PROMPT === 3. Registering the vector object in the common application permission model === PROMPT === 3. Registering the vector object in the common application permission model ===
-- The application permission tables remain the source of truth. These rows -- The application permission tables remain the source of truth. Basic vector
-- make the standalone DDS example use the same role/TAG scenario as VPD: -- access is a role whitelist: the seeded application roles have one ALLOW
-- one ALLOW permission per role and multiple TAG rules combined with OR. -- permission plus an ALL rule and can search every vector chunk. TAG rules
-- The backoffice's DDS publish screen compiles these rows into the grants -- are reserved for a role deliberately configured with a row condition.
-- above (and excludes the protected EMBEDDING column by default).
DECLARE DECLARE
PROCEDURE ensure_vector_permission( PROCEDURE ensure_vector_all_permission(p_role_id IN NUMBER) IS
p_role_id IN NUMBER,
p_tag_list IN SYS.ODCIVARCHAR2LIST
) IS
v_perm_id NUMBER; v_perm_id NUMBER;
v_rule_id NUMBER; v_rule_id NUMBER;
v_tag VARCHAR2(100);
BEGIN BEGIN
BEGIN BEGIN
SELECT perm_id SELECT perm_id
@@ -111,33 +106,36 @@ DECLARE
); );
END; END;
FOR i IN 1 .. p_tag_list.COUNT LOOP -- The old demo seed put arbitrary TECH_TAG filters on these base roles.
v_tag := UPPER(TRIM(p_tag_list(i))); -- Full access must not depend on the current list of document tags.
BEGIN DELETE FROM cb_permission_rule
SELECT rule_id WHERE perm_id = v_perm_id
AND UPPER(TRIM(rule_type)) = 'TAG';
SELECT MIN(rule_id)
INTO v_rule_id INTO v_rule_id
FROM cb_permission_rule FROM cb_permission_rule
WHERE perm_id = v_perm_id WHERE perm_id = v_perm_id
AND rule_column = 'TECH_TAG' AND UPPER(TRIM(rule_type)) = 'ALL';
AND rule_type = 'TAG'
AND rule_value = v_tag IF v_rule_id IS NULL THEN
AND ROWNUM = 1;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule; SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule;
INSERT INTO cb_permission_rule ( INSERT INTO cb_permission_rule (
rule_id, perm_id, rule_column, rule_type, rule_value rule_id, perm_id, rule_column, rule_type, rule_value
) VALUES ( ) VALUES (
v_rule_id, v_perm_id, 'TECH_TAG', 'TAG', v_tag v_rule_id, v_perm_id, NULL, 'ALL', NULL
); );
END; END IF;
END LOOP;
DELETE FROM cb_permission_rule
WHERE perm_id = v_perm_id
AND UPPER(TRIM(rule_type)) = 'ALL'
AND rule_id <> v_rule_id;
END; END;
BEGIN BEGIN
ensure_vector_permission(10, SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS')); ensure_vector_all_permission(10);
ensure_vector_permission(20, SYS.ODCIVARCHAR2LIST('ORACLE_VPD', 'ORACLE_DDS')); ensure_vector_all_permission(20);
ensure_vector_permission(30, SYS.ODCIVARCHAR2LIST( ensure_vector_all_permission(30);
'SPRING_BOOT', 'ORDS', 'ORACLE_VPD', 'ORACLE_DDS', 'MCP'));
END; END;
/ /

View File

@@ -0,0 +1,28 @@
-- ============================================================
-- 34_agent_ords_execution_evidence_grant.sql
-- Enable SQL_ID and DBMS_XPLAN evidence for VPD probe requests.
--
-- Run as SYS or a DBA account that can grant SYS.V_$ dynamic-performance
-- views. Typical Autonomous ADMIN accounts cannot delegate these views.
-- The grants are read-only and scoped to the BACKOFFICE_DB_USERNAME account
-- that displays the evidence (for example, ADMIN). They do not grant table
-- DML or alter DBMS_RLS enforcement.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
-- Change ADMIN when BACKOFFICE_DB_USERNAME uses a dedicated observer account.
DEFINE diagnostic_user = ADMIN
PROMPT === Granting backoffice cursor-evidence read access ===
GRANT SELECT ON V_$SQL TO &diagnostic_user;
GRANT SELECT ON V_$SQL_PLAN TO &diagnostic_user;
GRANT SELECT ON V_$SQL_PLAN_STATISTICS_ALL TO &diagnostic_user;
GRANT SELECT ON V_$SESSION TO &diagnostic_user;
GRANT EXECUTE ON DBMS_XPLAN TO &diagnostic_user;
PROMPT === Backoffice cursor-evidence grants ready ===
PROMPT SQL_ID evidence will appear after the next /probe call.
UNDEFINE diagnostic_user
EXIT;

View File

@@ -0,0 +1,173 @@
-- ============================================================
-- 36_dds_sales_knowledge_scenario.sql
-- Business scenario fixture: a sales employee searches only SALES-tagged
-- vector knowledge with a bearer token.
--
-- The access value is stored in CB_VECTOR_DOCUMENT_TAG.TECH_TAG. The
-- existing DDS token Data Grant (34_...) reads the common CB_* permission
-- tables at query time, so this script only adds the business user, role,
-- permission rule, sample chunk, and a short-lived demo token.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === 1. Adding the sales business user and role ===
MERGE INTO cb_app_user target
USING (
SELECT 104 user_id, 'agent_sales' user_name, 'E3001' employee_no,
'SALES' dept_code, 'N' can_read_contents, 'Y' active
FROM dual
) source
ON (target.user_id = source.user_id)
WHEN MATCHED THEN UPDATE SET
target.user_name = source.user_name,
target.employee_no = source.employee_no,
target.dept_code = source.dept_code,
target.can_read_contents = source.can_read_contents,
target.active = source.active
WHEN NOT MATCHED THEN INSERT (
user_id, user_name, employee_no, dept_code, can_read_contents, active
) VALUES (
source.user_id, source.user_name, source.employee_no,
source.dept_code, source.can_read_contents, source.active
);
MERGE INTO cb_app_role target
USING (
SELECT 40 role_id, 'SALES_KNOWLEDGE_ROLE' role_name,
'INTERNAL' max_sensitivity_level
FROM dual
) source
ON (target.role_id = source.role_id)
WHEN MATCHED THEN UPDATE SET
target.role_name = source.role_name,
target.max_sensitivity_level = source.max_sensitivity_level
WHEN NOT MATCHED THEN INSERT (
role_id, role_name, max_sensitivity_level
) VALUES (
source.role_id, source.role_name, source.max_sensitivity_level
);
MERGE INTO cb_user_role target
USING (SELECT 104 user_id, 40 role_id FROM dual) source
ON (target.user_id = source.user_id AND target.role_id = source.role_id)
WHEN NOT MATCHED THEN INSERT (user_id, role_id)
VALUES (source.user_id, source.role_id);
PROMPT === 2. Registering SALES as the allowed knowledge value ===
MERGE INTO cb_permission target
USING (
SELECT 400 perm_id, 40 role_id, 'CB_VECTOR_SEARCH_DOCUMENTS' target_name,
'SELECT' action_name, 'ALLOW' permission_effect
FROM dual
) source
ON (target.perm_id = source.perm_id)
WHEN MATCHED THEN UPDATE SET
target.role_id = source.role_id,
target.target_name = source.target_name,
target.action_name = source.action_name,
target.permission_effect = source.permission_effect
WHEN NOT MATCHED THEN INSERT (
perm_id, role_id, target_name, action_name, permission_effect
) VALUES (
source.perm_id, source.role_id, source.target_name,
source.action_name, source.permission_effect
);
MERGE INTO cb_permission_rule target
USING (
SELECT 4000 rule_id, 400 perm_id, 'TECH_TAG' rule_column,
'TAG' rule_type, 'SALES' rule_value
FROM dual
) source
ON (target.rule_id = source.rule_id)
WHEN MATCHED THEN UPDATE SET
target.perm_id = source.perm_id,
target.rule_column = source.rule_column,
target.rule_type = source.rule_type,
target.rule_value = source.rule_value
WHEN NOT MATCHED THEN INSERT (
rule_id, perm_id, rule_column, rule_type, rule_value
) VALUES (
source.rule_id, source.perm_id, source.rule_column,
source.rule_type, source.rule_value
);
PROMPT === 3. Adding a SALES-tagged vector knowledge chunk ===
MERGE INTO cb_vector_document_chunk target
USING (
SELECT 28004 chunk_id, 'knowledge-sales-001' document_id, 1 chunk_no,
'세일즈 파이프라인 운영 가이드' title,
TO_CLOB('세일즈 파이프라인 단계별 관리 기준과 영업 담당자용 고객 후속 조치 가이드입니다.') chunk_text,
'kb://business/sales/pipeline' source_uri,
TO_VECTOR('[0.65,0.25,0.15,0.10]') embedding
FROM dual
) source
ON (target.chunk_id = source.chunk_id)
WHEN MATCHED THEN UPDATE SET
target.document_id = source.document_id,
target.chunk_no = source.chunk_no,
target.title = source.title,
target.chunk_text = source.chunk_text,
target.source_uri = source.source_uri,
target.embedding = source.embedding
WHEN NOT MATCHED THEN INSERT (
chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding
) VALUES (
source.chunk_id, source.document_id, source.chunk_no, source.title,
source.chunk_text, source.source_uri, source.embedding
);
MERGE INTO cb_vector_document_tag target
USING (SELECT 28004 chunk_id, 'SALES' tech_tag FROM dual) source
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag)
VALUES (source.chunk_id, source.tech_tag);
MERGE INTO cb_vector_document_tag target
USING (SELECT 28004 chunk_id, 'INTERNAL' tech_tag FROM dual) source
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag)
VALUES (source.chunk_id, source.tech_tag);
PROMPT === 4. Issuing a short-lived demo token ===
DECLARE
v_key_id NUMBER;
BEGIN
UPDATE cb_agent_bearer_key
SET user_id = 104,
key_hash = STANDARD_HASH('dds_sales_demo_token', 'SHA256'),
active = 'Y',
revoked_at = NULL,
expires_at = SYSDATE + (1 / 24)
WHERE key_prefix = 'sales_demo';
IF SQL%ROWCOUNT = 0 THEN
SELECT NVL(MAX(key_id), 0) + 1 INTO v_key_id FROM cb_agent_bearer_key;
INSERT INTO cb_agent_bearer_key (
key_id, user_id, key_hash, key_prefix, issued_at,
expires_at, revoked_at, active
) VALUES (
v_key_id, 104, STANDARD_HASH('dds_sales_demo_token', 'SHA256'),
'sales_demo', SYSDATE, SYSDATE + (1 / 24), NULL, 'Y'
);
END IF;
END;
/
COMMIT;
SELECT u.user_name, u.dept_code, r.role_name,
p.target_name, pr.rule_column, pr.rule_value,
'dds_sales_demo_token' AS demo_token
FROM cb_app_user u
JOIN cb_user_role ur ON ur.user_id = u.user_id
JOIN cb_app_role r ON r.role_id = ur.role_id
JOIN cb_permission p ON p.role_id = r.role_id
JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id
WHERE u.user_id = 104;
PROMPT Sales scenario ready: use dds_sales_demo_token in the DDS token search.
EXIT;

View File

@@ -0,0 +1,18 @@
-- Remove only the SALES knowledge scenario fixture.
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
SET DEFINE OFF
DELETE FROM cb_agent_bearer_key WHERE key_prefix = 'sales_demo';
DELETE FROM cb_vector_document_tag WHERE chunk_id = 28004;
DELETE FROM cb_vector_document_chunk WHERE chunk_id = 28004;
DELETE FROM cb_permission_rule WHERE rule_id = 4000;
DELETE FROM cb_permission WHERE perm_id = 400;
DELETE FROM cb_user_role WHERE user_id = 104 AND role_id = 40;
DELETE FROM cb_app_role WHERE role_id = 40;
DELETE FROM cb_app_user WHERE user_id = 104;
COMMIT;
PROMPT Sales knowledge scenario removed.
EXIT;

View File

@@ -0,0 +1,91 @@
-- ============================================================
-- 38_agent_ords_vector_all_permission_normalization.sql
-- Normalize the seeded vector access roles to whitelist / ALL access.
--
-- The default roles 10, 20 and 30 may search every document chunk. A role
-- with no matching ALLOW permission remains fail-closed through the VPD
-- function (1 = 0). Keep row conditions such as TAG on dedicated restricted
-- roles that do not also have an ALL rule.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Normalizing default vector roles to ALL ===
DECLARE
PROCEDURE normalize_vector_role(p_role_id IN NUMBER) IS
v_perm_id NUMBER;
v_rule_id NUMBER;
BEGIN
SELECT MIN(perm_id)
INTO v_perm_id
FROM cb_permission
WHERE role_id = p_role_id
AND target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND action_name = 'SELECT'
AND permission_effect = 'ALLOW';
IF v_perm_id IS NULL THEN
SELECT NVL(MAX(perm_id), 0) + 1 INTO v_perm_id FROM cb_permission;
INSERT INTO cb_permission (
perm_id, role_id, target_name, action_name, permission_effect
) VALUES (
v_perm_id, p_role_id, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'
);
END IF;
-- Remove only the legacy demo TAG filters from the default ALL roles.
DELETE FROM cb_permission_rule
WHERE perm_id = v_perm_id
AND UPPER(TRIM(rule_type)) = 'TAG';
SELECT MIN(rule_id)
INTO v_rule_id
FROM cb_permission_rule
WHERE perm_id = v_perm_id
AND UPPER(TRIM(rule_type)) = 'ALL';
IF v_rule_id IS NULL THEN
SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule;
INSERT INTO cb_permission_rule (
rule_id, perm_id, rule_column, rule_type, rule_value
) VALUES (
v_rule_id, v_perm_id, NULL, 'ALL', NULL
);
END IF;
DELETE FROM cb_permission_rule
WHERE perm_id = v_perm_id
AND UPPER(TRIM(rule_type)) = 'ALL'
AND rule_id <> v_rule_id;
END;
BEGIN
normalize_vector_role(10); -- HR_DEPT_ROLE
normalize_vector_role(20); -- FIN_SELF_ROLE
normalize_vector_role(30); -- ALL_DOC_ROLE
COMMIT;
END;
/
PROMPT === Verifying default vector whitelist rules ===
SELECT r.role_id,
r.role_name,
p.target_name,
p.action_name,
p.permission_effect,
pr.rule_type,
pr.rule_column,
pr.rule_value
FROM cb_app_role r
JOIN cb_permission p
ON p.role_id = r.role_id
LEFT JOIN cb_permission_rule pr
ON pr.perm_id = p.perm_id
WHERE r.role_id IN (10, 20, 30)
AND p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
ORDER BY r.role_id, p.perm_id, pr.rule_id;
PROMPT === Default vector whitelist normalization complete ===
EXIT;

View File

@@ -0,0 +1,47 @@
-- ============================================================
-- 39_agent_ords_default_user_display_name.sql
-- Give the seeded verification users human-readable display names.
-- User IDs, roles, bearer keys and VPD authorization remain unchanged.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Renaming default verification users ===
DECLARE
PROCEDURE rename_user(
p_user_id IN NUMBER,
p_user_name IN VARCHAR2
) IS
BEGIN
UPDATE cb_app_user
SET user_name = p_user_name
WHERE user_id = p_user_id;
IF SQL%ROWCOUNT <> 1 THEN
RAISE_APPLICATION_ERROR(-20839, 'Default verification user not found: ' || p_user_id);
END IF;
END;
BEGIN
rename_user(101, '이에이치알'); -- HR_DEPT_ROLE
rename_user(102, '박파이넨스'); -- FIN_SELF_ROLE
rename_user(103, '김어드민'); -- ALL_DOC_ROLE
COMMIT;
END;
/
PROMPT === Verifying renamed users and their roles ===
SELECT u.user_id,
u.user_name,
u.employee_no,
u.dept_code,
r.role_name
FROM cb_app_user u
LEFT JOIN cb_user_role ur ON ur.user_id = u.user_id
LEFT JOIN cb_app_role r ON r.role_id = ur.role_id
WHERE u.user_id IN (101, 102, 103)
ORDER BY u.user_id, r.role_id;
PROMPT === Default verification user display names updated ===
EXIT;

View File

@@ -0,0 +1,211 @@
-- ============================================================
-- 40_dds_protection_evidence.sql
-- Immutable DDS publication fingerprints and deterministic validation fixtures.
--
-- Prerequisite: 25, 32, 34. Run as ADMIN.
-- The script is idempotent and never prints a bearer secret.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === 1. Evidence metadata ===
BEGIN
EXECUTE IMMEDIATE 'CREATE SEQUENCE cb_dds_protection_snapshot_seq START WITH 1 INCREMENT BY 1 NOCACHE';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
BEGIN
EXECUTE IMMEDIATE 'CREATE SEQUENCE cb_dds_validation_evidence_seq START WITH 1 INCREMENT BY 1 NOCACHE';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE cb_dds_protection_snapshot (
snapshot_id NUMBER PRIMARY KEY,
object_name VARCHAR2(261) NOT NULL,
enforcement_path VARCHAR2(20) NOT NULL CHECK (enforcement_path IN ('SERVICE','DIRECT')),
subject_key VARCHAR2(100) NOT NULL,
fingerprint VARCHAR2(64) NOT NULL,
actor VARCHAR2(100) NOT NULL,
note VARCHAR2(500),
published_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)]';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE cb_dds_validation_fixture (
fixture_id VARCHAR2(100) PRIMARY KEY,
enforcement_path VARCHAR2(20) NOT NULL CHECK (enforcement_path IN ('SERVICE','DIRECT')),
subject_key VARCHAR2(100) NOT NULL,
application_user_id NUMBER DEFAULT 0 NOT NULL,
document_id VARCHAR2(200) NOT NULL,
expected_visible CHAR(1) NOT NULL CHECK (expected_visible IN ('Y','N')),
description VARCHAR2(500) NOT NULL,
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y','N')),
fixture_version VARCHAR2(40) DEFAULT 'v1' NOT NULL
)]';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE cb_dds_validation_evidence (
evidence_id NUMBER PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
fixture_id VARCHAR2(100) NOT NULL,
enforcement_path VARCHAR2(20) NOT NULL CHECK (enforcement_path IN ('SERVICE','DIRECT')),
subject_key VARCHAR2(100) NOT NULL,
expected_visible CHAR(1) NOT NULL CHECK (expected_visible IN ('Y','N')),
actual_visible CHAR(1) NOT NULL CHECK (actual_visible IN ('Y','N')),
outcome VARCHAR2(20) NOT NULL CHECK (outcome IN ('PASSED','FAILED')),
fingerprint VARCHAR2(64) NOT NULL,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)]';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
BEGIN
EXECUTE IMMEDIATE 'CREATE INDEX cb_dds_snapshot_ix ON cb_dds_protection_snapshot (enforcement_path, subject_key, published_at)';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
BEGIN
EXECUTE IMMEDIATE 'CREATE INDEX cb_dds_validation_ix ON cb_dds_validation_evidence (enforcement_path, subject_key, created_at)';
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
/
PROMPT === 2. Deterministic SERVICE fixtures ===
MERGE INTO cb_app_user target
USING (SELECT 9904 user_id, 'dds_fixture_agent' user_name, 'E9904' employee_no,
'DDS' dept_code, 'N' can_read_contents, 'Y' active FROM dual) source
ON (target.user_id = source.user_id)
WHEN MATCHED THEN UPDATE SET target.user_name = source.user_name, target.employee_no = source.employee_no,
target.dept_code = source.dept_code, target.can_read_contents = source.can_read_contents, target.active = source.active
WHEN NOT MATCHED THEN INSERT (user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (source.user_id, source.user_name, source.employee_no, source.dept_code, source.can_read_contents, source.active);
MERGE INTO cb_app_role target
USING (SELECT 9904 role_id, 'DDS_FIXTURE_ALLOW_ROLE' role_name, 'INTERNAL' max_sensitivity_level FROM dual) source
ON (target.role_id = source.role_id)
WHEN MATCHED THEN UPDATE SET target.role_name = source.role_name, target.max_sensitivity_level = source.max_sensitivity_level
WHEN NOT MATCHED THEN INSERT (role_id, role_name, max_sensitivity_level)
VALUES (source.role_id, source.role_name, source.max_sensitivity_level);
MERGE INTO cb_user_role target
USING (SELECT 9904 user_id, 9904 role_id FROM dual) source
ON (target.user_id = source.user_id AND target.role_id = source.role_id)
WHEN NOT MATCHED THEN INSERT (user_id, role_id) VALUES (source.user_id, source.role_id);
MERGE INTO cb_permission target
USING (SELECT 9904 perm_id, 9904 role_id, 'CB_VECTOR_SEARCH_DOCUMENTS' target_name,
'SELECT' action_name, 'ALLOW' permission_effect FROM dual) source
ON (target.perm_id = source.perm_id)
WHEN MATCHED THEN UPDATE SET target.role_id = source.role_id, target.target_name = source.target_name,
target.action_name = source.action_name, target.permission_effect = source.permission_effect
WHEN NOT MATCHED THEN INSERT (perm_id, role_id, target_name, action_name, permission_effect)
VALUES (source.perm_id, source.role_id, source.target_name, source.action_name, source.permission_effect);
MERGE INTO cb_permission_rule target
USING (SELECT 9904 rule_id, 9904 perm_id, 'TECH_TAG' rule_column, 'TAG' rule_type,
'DDS_VERIFY_ALLOW' rule_value FROM dual) source
ON (target.rule_id = source.rule_id)
WHEN MATCHED THEN UPDATE SET target.perm_id = source.perm_id, target.rule_column = source.rule_column,
target.rule_type = source.rule_type, target.rule_value = source.rule_value
WHEN NOT MATCHED THEN INSERT (rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (source.rule_id, source.perm_id, source.rule_column, source.rule_type, source.rule_value);
MERGE INTO cb_vector_document_chunk target
USING (SELECT 29040 chunk_id, 'dds-fixture-service-allow' document_id, 1 chunk_no,
'DDS verification allow fixture' title, TO_CLOB('Deterministic allow fixture.') chunk_text,
'fixture://dds/service/allow' source_uri, TO_VECTOR('[0.11,0.22,0.33,0.44]') embedding FROM dual) source
ON (target.chunk_id = source.chunk_id)
WHEN MATCHED THEN UPDATE SET target.document_id = source.document_id, target.chunk_no = source.chunk_no,
target.title = source.title, target.chunk_text = source.chunk_text, target.source_uri = source.source_uri, target.embedding = source.embedding
WHEN NOT MATCHED THEN INSERT (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
VALUES (source.chunk_id, source.document_id, source.chunk_no, source.title, source.chunk_text, source.source_uri, source.embedding);
MERGE INTO cb_vector_document_chunk target
USING (SELECT 29041 chunk_id, 'dds-fixture-service-deny' document_id, 1 chunk_no,
'DDS verification deny fixture' title, TO_CLOB('Deterministic deny fixture.') chunk_text,
'fixture://dds/service/deny' source_uri, TO_VECTOR('[0.12,0.23,0.34,0.45]') embedding FROM dual) source
ON (target.chunk_id = source.chunk_id)
WHEN MATCHED THEN UPDATE SET target.document_id = source.document_id, target.chunk_no = source.chunk_no,
target.title = source.title, target.chunk_text = source.chunk_text, target.source_uri = source.source_uri, target.embedding = source.embedding
WHEN NOT MATCHED THEN INSERT (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
VALUES (source.chunk_id, source.document_id, source.chunk_no, source.title, source.chunk_text, source.source_uri, source.embedding);
MERGE INTO cb_vector_document_chunk target
USING (SELECT 29042 chunk_id, 'dds-fixture-direct' document_id, 1 chunk_no,
'DDS direct comparison fixture' title, TO_CLOB('Deterministic direct fixture.') chunk_text,
'fixture://dds/direct' source_uri, TO_VECTOR('[0.13,0.24,0.35,0.46]') embedding FROM dual) source
ON (target.chunk_id = source.chunk_id)
WHEN MATCHED THEN UPDATE SET target.document_id = source.document_id, target.chunk_no = source.chunk_no,
target.title = source.title, target.chunk_text = source.chunk_text, target.source_uri = source.source_uri, target.embedding = source.embedding
WHEN NOT MATCHED THEN INSERT (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
VALUES (source.chunk_id, source.document_id, source.chunk_no, source.title, source.chunk_text, source.source_uri, source.embedding);
MERGE INTO cb_vector_document_tag target USING (SELECT 29040 chunk_id, 'DDS_VERIFY_ALLOW' tech_tag FROM dual) source
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag) VALUES (source.chunk_id, source.tech_tag);
MERGE INTO cb_vector_document_tag target USING (SELECT 29041 chunk_id, 'DDS_VERIFY_DENY' tech_tag FROM dual) source
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag) VALUES (source.chunk_id, source.tech_tag);
MERGE INTO cb_vector_document_tag target USING (SELECT 29042 chunk_id, 'ORACLE_DDS' tech_tag FROM dual) source
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag) VALUES (source.chunk_id, source.tech_tag);
PROMPT === 3. Registering fixture manifest ===
MERGE INTO cb_dds_validation_fixture target
USING (SELECT 'SERVICE_ALLOW' fixture_id, 'SERVICE' enforcement_path, 'service' subject_key, 9904 application_user_id,
'dds-fixture-service-allow' document_id, 'Y' expected_visible, 'Temporary token sees allowed tag' description FROM dual) source
ON (target.fixture_id = source.fixture_id)
WHEN MATCHED THEN UPDATE SET target.enforcement_path = source.enforcement_path, target.subject_key = source.subject_key,
target.application_user_id = source.application_user_id, target.document_id = source.document_id,
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
MERGE INTO cb_dds_validation_fixture target
USING (SELECT 'SERVICE_DENY' fixture_id, 'SERVICE' enforcement_path, 'service' subject_key, 9904 application_user_id,
'dds-fixture-service-deny' document_id, 'N' expected_visible, 'Temporary token cannot see denied tag' description FROM dual) source
ON (target.fixture_id = source.fixture_id)
WHEN MATCHED THEN UPDATE SET target.enforcement_path = source.enforcement_path, target.subject_key = source.subject_key,
target.application_user_id = source.application_user_id, target.document_id = source.document_id,
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
MERGE INTO cb_dds_validation_fixture target
USING (SELECT 'DIRECT_ALLOW' fixture_id, 'DIRECT' enforcement_path, 'both' subject_key, 0 application_user_id,
'dds-fixture-direct' document_id, 'Y' expected_visible, 'Combined DDS profile sees direct fixture' description FROM dual) source
ON (target.fixture_id = source.fixture_id)
WHEN MATCHED THEN UPDATE SET target.subject_key = source.subject_key, target.document_id = source.document_id,
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
MERGE INTO cb_dds_validation_fixture target
USING (SELECT 'DIRECT_DENY' fixture_id, 'DIRECT' enforcement_path, 'none' subject_key, 0 application_user_id,
'dds-fixture-direct' document_id, 'N' expected_visible, 'Default deny DDS profile cannot see fixture' description FROM dual) source
ON (target.fixture_id = source.fixture_id)
WHEN MATCHED THEN UPDATE SET target.subject_key = source.subject_key, target.document_id = source.document_id,
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
PROMPT === 4. Recording the service baseline ===
MERGE INTO cb_dds_protection_snapshot target
USING (
SELECT 'SERVICE' enforcement_path, 'service' subject_key,
STANDARD_HASH('SERVICE|ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS|CB_DDS_TOKEN_ROLE|DDS_DEMO_TOKEN_VECTOR_GRANT|ADMIN.CB_DDS_VECTOR_TAG_ALLOWED', 'SHA256') fingerprint
FROM dual
) source
ON (target.enforcement_path = source.enforcement_path AND target.subject_key = source.subject_key)
WHEN NOT MATCHED THEN INSERT (snapshot_id, object_name, enforcement_path, subject_key, fingerprint, actor, note, published_at)
VALUES (cb_dds_protection_snapshot_seq.NEXTVAL, 'ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS', source.enforcement_path,
source.subject_key, source.fingerprint, 'DDS migration', 'Initial token-service baseline', SYSTIMESTAMP);
COMMIT;
SELECT fixture_id, enforcement_path, subject_key, expected_visible
FROM cb_dds_validation_fixture
ORDER BY fixture_id;
PROMPT === DDS protection evidence setup complete ===
EXIT;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,244 @@
-- ============================================================
-- 44_dds_mcp_local_end_user_setup.sql
--
-- Publishes every active CB_APP_USER as a passwordless local DDS END USER
-- for the DDS SSE MCP path. The application executes one protected query
-- per request with an EndUserSecurityContext; direct DB logon is not enabled.
--
-- Permission source: CB_APP_USER + direct/group role assignments +
-- CB_PERMISSION / CB_PERMISSION_RULE / CB_PERMISSION_COLUMN.
-- Current protected target: ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS.
--
-- Prerequisite: 25_agent_ords_security_backoffice_support.sql,
-- 28_agent_ords_vector_tag_vpd_setup.sql,
-- 32_dds_vector_tag_setup.sql.
-- Run as ADMIN on Oracle AI Database 26ai.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === 1. Persisting the published local END USER mapping ===
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE cb_dds_end_user_map (
application_user_id NUMBER PRIMARY KEY REFERENCES cb_app_user(user_id),
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
lookup_key_ref VARCHAR2(128) NOT NULL,
grant_name VARCHAR2(128) NOT NULL,
publish_status VARCHAR2(20) NOT NULL
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
published_at TIMESTAMP,
last_error VARCHAR2(1000)
)]';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE <> -955 THEN RAISE; END IF;
END;
/
MERGE INTO cb_dds_end_user_map target
USING (
SELECT user_id AS application_user_id,
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
FROM cb_app_user
) source
ON (target.application_user_id = source.application_user_id)
WHEN MATCHED THEN UPDATE SET
target.end_user_name = source.end_user_name,
target.data_role_name = source.data_role_name,
target.lookup_key_ref = source.lookup_key_ref,
target.grant_name = source.grant_name
WHEN NOT MATCHED THEN INSERT (
application_user_id, end_user_name, data_role_name, lookup_key_ref,
grant_name, publish_status
) VALUES (
source.application_user_id, source.end_user_name, source.data_role_name,
source.lookup_key_ref, source.grant_name, 'PENDING'
);
PROMPT === 2. Publishing END USER, DATA ROLE, and configured vector grants ===
DECLARE
v_predicate VARCHAR2(32767);
v_excluded_columns VARCHAR2(32767);
v_select_clause VARCHAR2(32767);
PROCEDURE drop_data_grant(p_grant_name IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'DROP DATA GRANT ADMIN.' || p_grant_name;
EXCEPTION
WHEN OTHERS THEN NULL; -- First publish has no generated grant.
END;
PROCEDURE revoke_data_role(p_role_name IN VARCHAR2, p_end_user_name IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'REVOKE DATA ROLE ' || p_role_name || ' FROM "' || p_end_user_name || '"';
EXCEPTION
WHEN OTHERS THEN NULL; -- Revoke is intentionally idempotent in this publisher.
END;
FUNCTION vector_predicate(p_user_id IN NUMBER) RETURN VARCHAR2 IS
v_allow VARCHAR2(32767);
v_deny VARCHAR2(32767);
v_clause VARCHAR2(2000);
v_effect VARCHAR2(10);
v_type VARCHAR2(30);
v_value VARCHAR2(100);
PROCEDURE append_clause(p_target IN OUT NOCOPY VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN RETURN; END IF;
IF p_target IS NULL THEN p_target := '(' || p_clause || ')';
ELSE p_target := p_target || ' OR (' || p_clause || ')'; END IF;
END;
BEGIN
FOR rule IN (
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE user_id = p_user_id
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = p_user_id
)
SELECT NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
UPPER(TRIM(r.rule_type)) AS rule_type,
TRIM(r.rule_value) AS rule_value
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_rule r ON r.perm_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
ORDER BY p.perm_id, r.rule_id
) LOOP
v_effect := rule.permission_effect;
v_type := rule.rule_type;
v_value := rule.rule_value;
v_clause := NULL;
IF v_type = 'ALL' THEN
v_clause := '1 = 1';
ELSIF v_type = 'TAG'
AND v_value IS NOT NULL
AND REGEXP_LIKE(v_value, '^[A-Za-z0-9_-]+$') THEN
v_clause := 'REGEXP_LIKE(UPPER(tech_tag), ''(^|,)' || UPPER(v_value) || '(,|$)'')';
END IF;
IF v_effect = 'DENY' THEN append_clause(v_deny, v_clause);
ELSE append_clause(v_allow, v_clause); END IF;
END LOOP;
IF v_allow IS NULL THEN RETURN NULL; END IF;
IF v_deny IS NULL THEN RETURN v_allow; END IF;
RETURN '(' || v_allow || ') AND NOT (' || v_deny || ')';
END;
FUNCTION excluded_vector_columns(p_user_id IN NUMBER) RETURN VARCHAR2 IS
v_columns VARCHAR2(32767);
BEGIN
SELECT LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name)
INTO v_columns
FROM (
SELECT pc.column_name
FROM cb_protected_column pc
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE po.object_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND pc.sensitive_yn = 'Y'
-- VECTOR_DISTANCE needs EMBEDDING internally. The MCP response never
-- returns it, but it must stay readable by the database engine.
AND pc.column_name <> 'EMBEDDING'
AND NOT EXISTS (
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE user_id = p_user_id
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = p_user_id
)
SELECT 1
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_column allowed ON allowed.permission_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND p.permission_effect = 'ALLOW'
AND allowed.column_name = pc.column_name
)
);
RETURN v_columns;
EXCEPTION
WHEN NO_DATA_FOUND THEN RETURN NULL;
END;
BEGIN
FOR mapped_user IN (
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
WHERE u.active = 'Y'
ORDER BY m.application_user_id
) LOOP
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|| ' TO "' || mapped_user.end_user_name || '"';
v_predicate := vector_predicate(mapped_user.application_user_id);
drop_data_grant(mapped_user.grant_name);
IF v_predicate IS NOT NULL THEN
v_excluded_columns := excluded_vector_columns(mapped_user.application_user_id);
v_select_clause := CASE WHEN v_excluded_columns IS NULL THEN 'AS SELECT'
ELSE 'AS SELECT (ALL COLUMNS EXCEPT ' || v_excluded_columns || ')' END;
EXECUTE IMMEDIATE 'CREATE DATA GRANT ADMIN.' || mapped_user.grant_name || ' '
|| v_select_clause || ' ON ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS WHERE '
|| v_predicate || ' TO ' || mapped_user.data_role_name;
END IF;
UPDATE cb_dds_end_user_map
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
WHERE application_user_id = mapped_user.application_user_id;
END LOOP;
-- A disabled application user must not retain a usable DDS role/grant.
FOR inactive_user IN (
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
WHERE u.active <> 'Y'
) LOOP
drop_data_grant(inactive_user.grant_name);
revoke_data_role(inactive_user.data_role_name, inactive_user.end_user_name);
UPDATE cb_dds_end_user_map
SET publish_status = 'REVOKED', published_at = SYSTIMESTAMP, last_error = NULL
WHERE application_user_id = inactive_user.application_user_id;
END LOOP;
COMMIT;
END;
/
PROMPT === 3. Published DDS MCP identity inventory ===
SELECT m.application_user_id,
u.user_name,
m.end_user_name,
m.data_role_name,
m.grant_name,
m.publish_status
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
ORDER BY m.application_user_id;
SELECT rg.grantee AS end_user_name,
rg.data_role,
dg.grant_name,
dg.object_name
FROM dba_data_role_grants rg
LEFT JOIN dba_data_grants dg ON dg.grantee = rg.data_role
WHERE rg.grantee LIKE 'DDS\_U\_%' ESCAPE '\'
ORDER BY rg.grantee, dg.grant_name;
PROMPT === DDS MCP local END USER publish complete ===
EXIT;

View File

@@ -0,0 +1,59 @@
-- ============================================================
-- 45_dds_mcp_local_end_user_test.sql
-- Catalog-level acceptance test for 44. Run as ADMIN.
-- The SSE/JDBC context assertion is performed by dds_vector_search after
-- the OCI IAM database-access token configuration is supplied.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET LINESIZE 220
SET PAGESIZE 200
DECLARE
v_missing_map NUMBER;
v_missing_end_user NUMBER;
v_missing_role NUMBER;
BEGIN
SELECT COUNT(*) INTO v_missing_map
FROM cb_app_user u
LEFT JOIN cb_dds_end_user_map m ON m.application_user_id = u.user_id
WHERE u.active = 'Y'
AND (m.application_user_id IS NULL OR m.publish_status <> 'PUBLISHED');
SELECT COUNT(*) INTO v_missing_end_user
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id AND u.active = 'Y'
LEFT JOIN dba_end_users eu ON eu.username = m.end_user_name
WHERE eu.username IS NULL;
SELECT COUNT(*) INTO v_missing_role
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id AND u.active = 'Y'
LEFT JOIN dba_data_role_grants rg
ON rg.grantee = m.end_user_name
AND rg.data_role = m.data_role_name
WHERE rg.data_role IS NULL;
IF v_missing_map <> 0 OR v_missing_end_user <> 0 OR v_missing_role <> 0 THEN
RAISE_APPLICATION_ERROR(-20944,
'DDS MCP publish validation failed: map=' || v_missing_map
|| ', end_user=' || v_missing_end_user || ', data_role=' || v_missing_role);
END IF;
END;
/
COLUMN user_name FORMAT A22
COLUMN end_user_name FORMAT A18
COLUMN data_role_name FORMAT A24
COLUMN grant_name FORMAT A34
SELECT u.user_name, m.end_user_name, m.data_role_name, m.grant_name,
CASE WHEN dg.grant_name IS NULL THEN 'DEFAULT_DENY' ELSE 'PUBLISHED' END AS vector_access
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
LEFT JOIN dba_data_grants dg ON dg.grant_name = m.grant_name
WHERE u.active = 'Y'
ORDER BY m.application_user_id;
PROMPT Catalog validation passed. Next: call /dds/mcp/sse and dds_vector_search with a valid MCP Bearer.
EXIT;

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
-- Smilegate PoC backoffice users. These are tool operators, not game players.
declare
table_missing exception;
pragma exception_init(table_missing, -955);
begin
execute immediate q'[
create table sg_tool_user (
user_id varchar2(100) not null,
user_nm varchar2(200) not null,
role_code varchar2(30) not null,
password_hash varchar2(200) not null,
enabled_yn char(1) default 'Y' not null,
created_at timestamp default systimestamp not null,
updated_at timestamp default systimestamp not null,
constraint sg_tool_user_pk primary key (user_id),
constraint sg_tool_user_role_ck check (role_code in ('TEAM_LEAD', 'TEAM_MEMBER')),
constraint sg_tool_user_enabled_ck check (enabled_yn in ('Y', 'N'))
)
]';
exception
when table_missing then null;
end;
/
merge into sg_tool_user target
using (
select 'sg-teamlead' as user_id, 'SG Demo Team Lead' as user_nm,
'TEAM_LEAD' as role_code,
'{bcrypt}$2y$12$esdNRjiRo3ZxBnUGD1xrjuIFjCeCWcxlksDfdZpeImTsVtIdh/IJe' as password_hash
from dual
union all
select 'sg-member', 'SG Demo Team Member', 'TEAM_MEMBER',
'{bcrypt}$2y$12$esdNRjiRo3ZxBnUGD1xrjuIFjCeCWcxlksDfdZpeImTsVtIdh/IJe'
from dual
) source
on (target.user_id = source.user_id)
when matched then update set
target.user_nm = source.user_nm,
target.role_code = source.role_code,
target.password_hash = source.password_hash,
target.enabled_yn = 'Y',
target.updated_at = systimestamp
when not matched then insert (user_id, user_nm, role_code, password_hash, enabled_yn)
values (source.user_id, source.user_nm, source.role_code, source.password_hash, 'Y');
commit;

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