Compare commits

38 Commits

Author SHA1 Message Date
devmrko
455e2aa222 refs #731: expose database game scope MCP 2026-07-27 13:43:59 +09:00
devmrko
a17f18cffe refs #731: add DB game scope metadata 2026-07-27 13:43:37 +09:00
devmrko
943c38f181 derive unresolved scope response from resolver status 2026-07-27 13:30:51 +09:00
devmrko
443d6677a3 add metadata-backed game scope resolver 2026-07-27 13:04:43 +09:00
devmrko
9c48d86696 generalize game scope guidance through metadata few-shot 2026-07-27 13:03:33 +09:00
devmrko
c58cdb5797 refs #736: route Smilegate portal to few-shot MCP 2026-07-27 10:01:44 +09:00
devmrko
e753027536 refs #735: add few-shot NL2SQL MCP tool 2026-07-24 16:21:01 +09:00
devmrko
da992ff524 refs #731: guard missing game identifiers in few-shot prompt 2026-07-24 16:14:18 +09:00
devmrko
00b62bfb41 refs #731: preserve Select AI configuration binding 2026-07-24 16:09:02 +09:00
devmrko
d552e5e25b refs #734: add validated annotation PL/SQL API 2026-07-24 16:03:50 +09:00
devmrko
3fcd699d92 refs #731: enrich Text2SQL prompts with QA examples 2026-07-24 15:59:41 +09:00
devmrko
c8fa6af679 refs #731: fix autonomous vector example storage 2026-07-24 15:53:47 +09:00
devmrko
8e4d2f656f refs #731: allow QA vector MCP tools in portal 2026-07-24 14:45:08 +09:00
devmrko
df19912378 refs #731: add QA vector MCP tools 2026-07-24 14:41:00 +09:00
devmrko
744dcad634 feat: add dedicated Cohere Embed 4 QA vector retrieval 2026-07-24 14:23:33 +09:00
devmrko
23bc4424e3 refs #726: add Select AI SHOWPROMPT diagnostic tool 2026-07-23 20:27:14 +09:00
devmrko
53342e7bc3 refs #722: externalize backoffice customer configuration 2026-07-23 19:14:37 +09:00
devmrko
7ba173240c refs #708: report configured Smilegate Select AI profile 2026-07-23 14:06:16 +09:00
devmrko
ebc8aeee41 refs #710: remove POC4 URL remember tokens 2026-07-23 14:03:20 +09:00
devmrko
75d0a9a998 refs #708: move Smilegate Select AI to OCI GPT 5.4 Mini 2026-07-23 13:45:55 +09:00
devmrko
32fccced20 refs #703: avoid blocking Smilegate backoffice rendering 2026-07-23 13:04:28 +09:00
devmrko
a46ebe6bfd refs #703: force Smilegate PoC HTTP 1.1 2026-07-23 12:57:59 +09:00
devmrko
1771dccd96 refs #703: use gzip for Smilegate backoffice 2026-07-23 12:50:45 +09:00
devmrko
cbee6c31c7 refs #703: fix Smilegate annotation metadata query 2026-07-23 10:46:11 +09:00
devmrko
f730c73ceb refs #703: log schema metadata lookup failures 2026-07-23 10:38:02 +09:00
devmrko
b106e40631 refs #703: route schema metadata through MyBatis 2026-07-23 10:29:22 +09:00
devmrko
99798aa6bb refs #706: add Smilegate QA history benchmark 2026-07-23 10:12:36 +09:00
devmrko
1fe8404e4b refs #703: cover Streamlit expander dark theme internals 2026-07-23 09:38:10 +09:00
devmrko
1fcc231eff refs #703: fix dark mode expander contrast 2026-07-22 20:53:02 +09:00
devmrko
63f294a857 refs #703: enforce chat answer contrast in dark mode 2026-07-22 20:49:28 +09:00
devmrko
20836e458d refs #703: fix dark mode secondary button contrast 2026-07-22 20:36:55 +09:00
devmrko
9eb1f4d1fd refs #703: preserve read-only transaction for Text2SQL 2026-07-22 20:19:47 +09:00
devmrko
914dc087ed refs #703: execute validated Smilegate Text2SQL 2026-07-22 20:15:10 +09:00
devmrko
27757ebff3 refs #703: configure OCI GenAI root compartment 2026-07-22 20:07:47 +09:00
devmrko
4211f11cae refs #703: include OCI SDK for console runtime 2026-07-22 16:29:37 +09:00
devmrko
a91ad3c364 refs #703: fix MCP detail contrast 2026-07-22 16:26:44 +09:00
devmrko
90d76f1033 refs #703: configure smilegate mcp token presets 2026-07-22 16:16:57 +09:00
devmrko
211dd2b2c8 refs #703 #704: finalize smilegate game data poc 2026-07-22 15:35:02 +09:00
525 changed files with 10524 additions and 11938 deletions

View File

@@ -43,8 +43,7 @@ export BACKOFFICE_SESSION_COOKIE_SECURE="false"
export BACKOFFICE_REMEMBER_ME_ENABLED="false" export BACKOFFICE_REMEMBER_ME_ENABLED="false"
export BACKOFFICE_REMEMBER_ME_KEY="" export BACKOFFICE_REMEMBER_ME_KEY=""
export BACKOFFICE_REMEMBER_ME_DAYS="14" export BACKOFFICE_REMEMBER_ME_DAYS="14"
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다. export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/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 생성은 막히고 소스 보기만 사용합니다.
@@ -52,42 +51,49 @@ 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=""
# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다. # Select AI 프로파일 소유 스키마로 별도 접속합니다.
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}" export BACKOFFICE_SELECT_AI_DB_USERNAME=""
export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}" export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
export BACKOFFICE_SELECT_AI_PROFILE="" export BACKOFFICE_SELECT_AI_PROFILE=""
# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다. export BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED="true"
# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요. export BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K="3"
export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}" # Customer-owned DB view: game aliases, approved profile objects, and valid DB objects.
export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS" export BACKOFFICE_GAME_SCOPE_ENABLED="false"
export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD="" export BACKOFFICE_GAME_SCOPE_VIEW=""
# Optional deployment-specific JSON contract. Keep project rules out of Java. export BACKOFFICE_GAME_SCOPE_MAX_SCOPES="8"
export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE=""
# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 --- # 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다. # 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
export BACKOFFICE_CATALOG_OWNER="APP_OWNER" export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]' export BACKOFFICE_CATALOG_OBJECTS='[{"key":"customers","tableName":"CUSTOMER","objectType":"TABLE","businessName":"고객","description":"고객 기본 정보"}]'
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice" export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice" export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터" 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_NAME="oracle.select_ai.data_text2sql"
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL" export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다." export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다." export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
export BACKOFFICE_MCP_TOOLS='' export BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME="oracle.select_ai.data_showprompt"
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL="업무 데이터 SHOWPROMPT"
# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다. export BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION="Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
# Select AI few-shot 예제 SQL 조회·저장 MCP. 운영 환경은 고객별 도구명과 안내문만 변경합니다.
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME="oracle.select_ai.qa_vector_search"
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL="Select AI 예제 SQL 조회"
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION="현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME="oracle.select_ai.qa_vector_store"
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL="Select AI 예제 SQL 저장"
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION="검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME="oracle.select_ai.game_scope_resolve"
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL="게임 조회 범위 확인"
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION="질문의 게임 별칭을 DB 범위 view로 확인하고, SUPPORTED 결과에만 Few-shot NL2SQL을 호출하도록 안내합니다."
# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다.
export BACKOFFICE_MASKING_POLICIES='' export BACKOFFICE_MASKING_POLICIES=''
# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다.
export BACKOFFICE_SECURITY_SQL_SCRIPTS='' export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- # --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false" export BACKOFFICE_AI_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci export BACKOFFICE_AI_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
@@ -106,7 +112,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
export VPDTEST1_API_KEY="" export VPDTEST1_API_KEY=""
export VPDTEST2_API_KEY="" export VPDTEST2_API_KEY=""
# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) --- # --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합. # ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
# 4명의 데모 유저: # 4명의 데모 유저:
# vpduser_my → MySQL view 만 SELECT 가능 # vpduser_my → MySQL view 만 SELECT 가능
@@ -119,7 +125,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 변형 데모용 엔드유저 비밀번호 ---
# database/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용. # sql/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"
@@ -140,7 +146,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용 export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) --- # --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
# database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성. # sql/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"

4
.gitignore vendored
View File

@@ -17,11 +17,9 @@ logs/
# Java / Maven # Java / Maven
target/ target/
# Python # Python / Streamlit
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/
data/
# Locally downloaded development tools (for example SQLcl) # Locally downloaded development tools (for example SQLcl)
.tools/ .tools/

View File

@@ -32,7 +32,7 @@ End-to-End 데모입니다.
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만 행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
`email` 컬럼은 항상 `NULL` 로 반환. `email` 컬럼은 항상 `NULL` 로 반환.
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`). * VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`).
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수. * DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
### 3. 두 가지 구현 — VPD 와 DDS ### 3. 두 가지 구현 — VPD 와 DDS
@@ -59,21 +59,19 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
## 소스 위치와 Git 저장 기준 ## 소스 위치와 Git 저장 기준
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은 현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다.
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다. 두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다.
| 구분 | 저장 위치 | 역할 | | 구분 | 저장 위치 | 역할 |
|---|---|---| |---|---|---|
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 | | VPD/ASO/FGA 관리 백오피스 | `src/main/java/com/cloudhandson/vpdbackoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 | | VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 | | VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 |
| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 | | VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 |
| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 | | DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 | | Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot |
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 | | Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 |
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 | | Streamlit 외피 MCP 설정 | `poc4_active_source_20260714/config/mcp_servers.json` | `kb_mcp`, `kb_vector_mcp` endpoint와 allowlist |
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
Git 기준은 다음과 같다. Git 기준은 다음과 같다.
@@ -82,12 +80,14 @@ Git 기준은 다음과 같다.
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` | | 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
| 운영 기준 remote | `gitea` | | 운영 기준 remote | `gitea` |
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` | | 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
| HMM 기준 branch | `hmm-backoffice` | | 기준 branch | `main` |
| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` |
주의: 주의:
- HMM 운영·현행화 기준은 Gitea`hmm-backoffice` 브랜치다. - 현재 운영·현행화 기준은 `gitea/main`다.
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다. - `origin/main`은 보조 remote이며, 이 작업공간의 `main``origin/main`보다 앞서 있을 수 있다.
- `poc4_active_source_20260714/`는 배포 VM의 PoC4 활성 소스를 가져온 snapshot이다. 원본은 배포 VM의 `/home/opc/poc_4/` 계열 경로에서 운영되었고, 저장소에는 비밀정보 없이 소스와 설정 sample만 보관한다.
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다. - 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
--- ---
@@ -97,7 +97,6 @@ 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` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
@@ -110,7 +109,7 @@ Git 기준은 다음과 같다.
## 빠른 시작 (One-Click) ## 빠른 시작 (One-Click)
```bash ```bash
git clone --branch hmm-backoffice https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git git clone https://github.com/<you>/vpd-permission-poc.git
cd vpd-permission-poc cd vpd-permission-poc
# 1) 환경값 채우기 # 1) 환경값 채우기
@@ -168,7 +167,7 @@ $EDITOR .env
## 데모 시나리오 — 2×2 source access matrix ## 데모 시나리오 — 2×2 source access matrix
`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스): `sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 | | DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|---|---|---|---|---| |---|---|---|---|---|
@@ -186,7 +185,7 @@ $EDITOR .env
(`1=0` predicate) — **deny by default**. (`1=0` predicate) — **deny by default**.
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음. * 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음.
`database/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트 `sql/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 행 수를 가볍게 확인합니다.
@@ -198,7 +197,7 @@ $EDITOR .env
## DDS 설정 핵심 ## DDS 설정 핵심
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이 DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다. 순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
### 사전 조건 ### 사전 조건
@@ -258,23 +257,10 @@ 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 검증 헬퍼
├── database/ ├── sql/
│ ├── 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

@@ -1,48 +0,0 @@
# 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

@@ -1,57 +0,0 @@
# 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

@@ -1,190 +0,0 @@
"""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

@@ -1,563 +0,0 @@
"""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

@@ -1,80 +0,0 @@
"""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

@@ -1,407 +0,0 @@
"""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

@@ -1,21 +0,0 @@
{
"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

@@ -1,84 +0,0 @@
{
"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

@@ -1,23 +0,0 @@
{
"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

@@ -1,52 +0,0 @@
{
"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

@@ -1,217 +0,0 @@
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

@@ -1,181 +0,0 @@
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

@@ -1,238 +0,0 @@
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

@@ -1,597 +0,0 @@
-- ============================================================
-- 72_hmm_leave_team_vpd.sql
--
-- HMM HR leave row security:
-- * HMM_HR_MANAGER -> self and directly managed employees
-- * HMM_HR_VIEWER -> self only
-- * HMM_HR_ADMIN -> all rows when the role is explicitly assigned
--
-- Run as ADMIN after the HMM access-management schema is installed.
-- This script is idempotent and never stores a plain bearer token.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
ALTER SESSION DISABLE PARALLEL DML;
PROMPT === 1. Ensuring HMM roles and demo role assignments ===
MERGE INTO hmm_access_roles dst
USING (
SELECT 1 role_id, 'HMM_HR_VIEWER' role_name,
'HMM employee self-service leave access' description,
'PUBLIC' max_sensitivity_level FROM dual
UNION ALL
SELECT 2, 'HMM_HR_MANAGER',
'HMM manager access to self and direct reports',
'INTERNAL' FROM dual
UNION ALL
SELECT 3, 'HMM_HR_ADMIN',
'HMM HR administrator full leave access',
'CONFIDENTIAL' FROM dual
) src
ON (dst.role_name = src.role_name)
WHEN MATCHED THEN UPDATE SET
dst.description = src.description,
dst.max_sensitivity_level = src.max_sensitivity_level,
dst.active_yn = 'Y',
dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (
role_id, role_name, description, max_sensitivity_level,
active_yn, created_at, updated_at
) VALUES (
src.role_id, src.role_name, src.description, src.max_sensitivity_level,
'Y', SYSTIMESTAMP, SYSTIMESTAMP
);
MERGE INTO hmm_employee_access_roles dst
USING (
SELECT e.employee_id, r.role_id
FROM hmm_hr_employees e
JOIN hmm_access_roles r ON r.role_name = 'HMM_HR_MANAGER'
WHERE e.employee_code = 'E1001'
AND e.employment_status = 'ACTIVE'
) src
ON (dst.employee_id = src.employee_id AND dst.role_id = src.role_id)
WHEN NOT MATCHED THEN INSERT (employee_id, role_id, created_at)
VALUES (src.employee_id, src.role_id, SYSTIMESTAMP);
MERGE INTO hmm_employee_access_roles dst
USING (
SELECT e.employee_id, r.role_id
FROM hmm_hr_employees e
JOIN hmm_access_roles r ON r.role_name = 'HMM_HR_VIEWER'
WHERE e.manager_employee_id = (
SELECT manager.employee_id
FROM hmm_hr_employees manager
WHERE manager.employee_code = 'E1001'
)
AND e.employment_status = 'ACTIVE'
) src
ON (dst.employee_id = src.employee_id AND dst.role_id = src.role_id)
WHEN NOT MATCHED THEN INSERT (employee_id, role_id, created_at)
VALUES (src.employee_id, src.role_id, SYSTIMESTAMP);
COMMIT;
PROMPT === 2. Ensuring protected leave objects and columns ===
DECLARE
PROCEDURE ensure_object(
p_object_name IN VARCHAR2,
p_description IN VARCHAR2
) AS
v_object_id NUMBER;
BEGIN
BEGIN
SELECT object_id
INTO v_object_id
FROM hmm_access_objects
WHERE owner = 'ADMIN'
AND object_name = p_object_name;
UPDATE hmm_access_objects
SET enabled_yn = 'Y',
description = p_description,
ords_path = 'cb-ords/cb-object-query/admin/' || LOWER(p_object_name)
WHERE object_id = v_object_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(object_id), 0) + 1
INTO v_object_id
FROM hmm_access_objects;
INSERT INTO hmm_access_objects (
object_id, owner, object_name, ords_path, enabled_yn, description
) VALUES (
v_object_id,
'ADMIN',
p_object_name,
'cb-ords/cb-object-query/admin/' || LOWER(p_object_name),
'Y',
p_description
);
END;
FOR c IN (
SELECT column_name
FROM user_tab_columns
WHERE table_name = p_object_name
ORDER BY column_id
) LOOP
MERGE INTO hmm_access_object_columns dst
USING (
SELECT v_object_id object_id, c.column_name column_name
FROM dual
) src
ON (
dst.object_id = src.object_id
AND dst.column_name = src.column_name
)
WHEN NOT MATCHED THEN INSERT (
column_id, object_id, column_name, sensitive_yn,
sensitivity_level, redaction_method
) VALUES (
(SELECT NVL(MAX(column_id), 0) + 1 FROM hmm_access_object_columns),
src.object_id,
src.column_name,
'N',
'PUBLIC',
'NONE'
);
END LOOP;
END;
BEGIN
ensure_object(
'HMM_LEAVE_BALANCES',
'HMM employee leave balance protected by employee hierarchy'
);
ensure_object(
'HMM_LEAVE_REQUESTS',
'HMM employee leave requests protected by employee hierarchy'
);
END;
/
COMMIT;
PROMPT === 3. Ensuring role permission rules ===
DECLARE
PROCEDURE ensure_permission(
p_role_name IN VARCHAR2,
p_target_name IN VARCHAR2,
p_rule_type IN VARCHAR2
) AS
v_role_id NUMBER;
v_perm_id NUMBER;
BEGIN
SELECT role_id
INTO v_role_id
FROM hmm_access_roles
WHERE role_name = p_role_name
AND active_yn = 'Y';
BEGIN
SELECT perm_id
INTO v_perm_id
FROM hmm_access_permissions
WHERE role_id = v_role_id
AND target_name = p_target_name
AND action_name = 'SELECT';
UPDATE hmm_access_permissions
SET permission_effect = 'ALLOW'
WHERE perm_id = v_perm_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT cb_permission_seq.NEXTVAL
INTO v_perm_id
FROM dual;
INSERT INTO hmm_access_permissions (
perm_id, role_id, target_name, action_name, permission_effect
) VALUES (
v_perm_id, v_role_id, p_target_name, 'SELECT', 'ALLOW'
);
END;
DELETE FROM hmm_access_permission_rules
WHERE perm_id = v_perm_id;
INSERT INTO hmm_access_permission_rules (
rule_id, perm_id, rule_column, rule_type, rule_value
) VALUES (
cb_permission_rule_seq.NEXTVAL,
v_perm_id,
'EMPLOYEE_ID',
p_rule_type,
NULL
);
END;
BEGIN
ensure_permission('HMM_HR_VIEWER', 'HMM_LEAVE_BALANCES', 'SELF');
ensure_permission('HMM_HR_VIEWER', 'HMM_LEAVE_REQUESTS', 'SELF');
ensure_permission('HMM_HR_MANAGER', 'HMM_LEAVE_BALANCES', 'MANAGED_TEAM');
ensure_permission('HMM_HR_MANAGER', 'HMM_LEAVE_REQUESTS', 'MANAGED_TEAM');
ensure_permission('HMM_HR_ADMIN', 'HMM_LEAVE_BALANCES', 'ALL');
ensure_permission('HMM_HR_ADMIN', 'HMM_LEAVE_REQUESTS', 'ALL');
END;
/
COMMIT;
PROMPT === 4. Creating secure HMM access context ===
CREATE OR REPLACE PACKAGE hmm_access_ctx_pkg AUTHID DEFINER AS
PROCEDURE clear_user;
PROCEDURE set_user_by_bearer(p_bearer_token IN VARCHAR2);
END;
/
CREATE OR REPLACE PACKAGE BODY hmm_access_ctx_pkg AS
PROCEDURE clear_user AS
BEGIN
DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID', NULL);
DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE', NULL);
DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'TEAM_ID', NULL);
END;
PROCEDURE set_user_by_bearer(p_bearer_token IN VARCHAR2) AS
v_employee_id hmm_hr_employees.employee_id%TYPE;
v_employee_code hmm_hr_employees.employee_code%TYPE;
v_team_id hmm_hr_employees.team_id%TYPE;
BEGIN
IF p_bearer_token IS NULL OR TRIM(p_bearer_token) IS NULL THEN
clear_user;
RAISE_APPLICATION_ERROR(-20101, 'Bearer token is required');
END IF;
SELECT e.employee_id,
e.employee_code,
e.team_id
INTO v_employee_id,
v_employee_code,
v_team_id
FROM hmm_access_bearer_tokens t
JOIN hmm_hr_employees e
ON e.employee_id = t.employee_id
WHERE t.key_hash = STANDARD_HASH(p_bearer_token, 'SHA256')
AND t.revoked_at IS NULL
AND t.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP)
AND e.employment_status = 'ACTIVE';
DBMS_SESSION.SET_CONTEXT(
'HMM_ACCESS_CTX', 'EMPLOYEE_ID', TO_CHAR(v_employee_id)
);
DBMS_SESSION.SET_CONTEXT(
'HMM_ACCESS_CTX', 'EMPLOYEE_CODE', v_employee_code
);
DBMS_SESSION.SET_CONTEXT(
'HMM_ACCESS_CTX', 'TEAM_ID', TO_CHAR(v_team_id)
);
EXCEPTION
WHEN NO_DATA_FOUND THEN
clear_user;
RAISE_APPLICATION_ERROR(-20102, 'Invalid or expired bearer token');
WHEN TOO_MANY_ROWS THEN
clear_user;
RAISE_APPLICATION_ERROR(-20103, 'Bearer token mapping is not unique');
END;
END;
/
CREATE OR REPLACE CONTEXT hmm_access_ctx USING hmm_access_ctx_pkg;
PROMPT === 5. Creating the HMM leave VPD filter ===
CREATE OR REPLACE FUNCTION hmm_leave_vpd_filter(
p_schema IN VARCHAR2,
p_object IN VARCHAR2
) RETURN VARCHAR2
AUTHID DEFINER
AS
v_employee_id NUMBER;
v_target VARCHAR2(128);
v_allow_predicate VARCHAR2(32767);
v_deny_predicate VARCHAR2(32767);
v_permission_clause VARCHAR2(32767);
FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
BEGIN
RETURN '''' || REPLACE(NVL(p_value, ''), '''', '''''') || '''';
END;
FUNCTION safe_column(p_column_name IN VARCHAR2) RETURN VARCHAR2 IS
v_column VARCHAR2(128);
v_count NUMBER;
BEGIN
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_schema)
AND table_name = UPPER(p_object)
AND column_name = v_column;
RETURN CASE WHEN v_count = 1 THEN v_column ELSE NULL END;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
PROCEDURE append_and(p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN
v_permission_clause := NULL;
ELSIF v_permission_clause IS NULL THEN
v_permission_clause := '(' || p_clause || ')';
ELSE
v_permission_clause := v_permission_clause || ' AND (' || p_clause || ')';
END IF;
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;
BEGIN
BEGIN
v_employee_id := TO_NUMBER(
SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')
);
EXCEPTION
WHEN OTHERS THEN
RETURN '1 = 0';
END;
IF v_employee_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));
IF v_target NOT IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') THEN
RETURN '1 = 0';
END IF;
FOR permission_row IN (
SELECT DISTINCT
p.perm_id,
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') permission_effect
FROM (
SELECT er.role_id
FROM hmm_employee_access_roles er
WHERE er.employee_id = v_employee_id
UNION
SELECT gr.role_id
FROM hmm_access_group_members gm
JOIN hmm_access_groups g
ON g.group_id = gm.group_id
AND g.active_yn = 'Y'
JOIN hmm_group_access_roles gr
ON gr.group_id = gm.group_id
WHERE gm.employee_id = v_employee_id
) effective_role
JOIN hmm_access_roles role
ON role.role_id = effective_role.role_id
AND role.active_yn = 'Y'
JOIN hmm_access_permissions p
ON p.role_id = role.role_id
AND p.target_name = v_target
AND p.action_name = 'SELECT'
ORDER BY p.perm_id
) LOOP
v_permission_clause := NULL;
FOR rule_row IN (
SELECT UPPER(TRIM(rule_type)) rule_type,
UPPER(TRIM(rule_column)) rule_column,
rule_value
FROM hmm_access_permission_rules
WHERE perm_id = permission_row.perm_id
ORDER BY rule_id
) LOOP
DECLARE
v_column VARCHAR2(128);
v_clause VARCHAR2(32767);
BEGIN
v_column := safe_column(NVL(rule_row.rule_column, 'EMPLOYEE_ID'));
v_clause := NULL;
IF rule_row.rule_type = 'ALL' THEN
v_clause := '1 = 1';
ELSIF rule_row.rule_type = 'SELF' AND v_column IS NOT NULL THEN
v_clause := v_column
|| ' = TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))';
ELSIF rule_row.rule_type = 'MANAGED_TEAM' AND v_column IS NOT NULL THEN
v_clause := v_column || ' IN ('
|| 'SELECT employee.employee_id '
|| 'FROM ADMIN.HMM_HR_EMPLOYEES employee '
|| 'WHERE employee.employee_id = '
|| 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID'')) '
|| 'OR employee.manager_employee_id = '
|| 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))'
|| ')';
ELSIF rule_row.rule_type = '='
AND v_column IS NOT NULL
AND rule_row.rule_value IS NOT NULL THEN
v_clause := 'TO_CHAR(' || v_column || ') = '
|| quote_literal(rule_row.rule_value);
ELSIF rule_row.rule_type IN ('!=', '<>')
AND v_column IS NOT NULL
AND rule_row.rule_value IS NOT NULL THEN
v_clause := 'TO_CHAR(' || v_column || ') <> '
|| quote_literal(rule_row.rule_value);
END IF;
IF v_clause IS NULL THEN
v_permission_clause := NULL;
EXIT;
END IF;
append_and(v_clause);
END;
END LOOP;
append_permission(
permission_row.permission_effect,
v_permission_clause
);
END LOOP;
IF v_allow_predicate IS NULL THEN
RETURN '1 = 0';
END IF;
IF v_deny_predicate IS NULL THEN
RETURN v_allow_predicate;
END IF;
RETURN '((' || v_allow_predicate || ') AND NOT ('
|| v_deny_predicate || '))';
EXCEPTION
WHEN OTHERS THEN
RETURN '1 = 0';
END;
/
SHOW ERRORS PACKAGE hmm_access_ctx_pkg
SHOW ERRORS PACKAGE BODY hmm_access_ctx_pkg
SHOW ERRORS FUNCTION hmm_leave_vpd_filter
PROMPT === 6. Attaching context-sensitive VPD policies ===
DECLARE
PROCEDURE replace_policy(p_object_name IN VARCHAR2) AS
BEGIN
BEGIN
DBMS_RLS.DROP_POLICY(
object_schema => 'ADMIN',
object_name => p_object_name,
policy_name => 'HMM_LEAVE_SCOPE_POLICY'
);
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -28102 THEN
RAISE;
END IF;
END;
DBMS_RLS.ADD_POLICY(
object_schema => 'ADMIN',
object_name => p_object_name,
policy_name => 'HMM_LEAVE_SCOPE_POLICY',
function_schema => 'ADMIN',
policy_function => 'HMM_LEAVE_VPD_FILTER',
statement_types => 'SELECT',
update_check => FALSE,
enable => TRUE,
policy_type => DBMS_RLS.CONTEXT_SENSITIVE
);
END;
BEGIN
replace_policy('HMM_LEAVE_BALANCES');
replace_policy('HMM_LEAVE_REQUESTS');
END;
/
PROMPT === 7. Recording backoffice descriptions ===
MERGE INTO hmm_access_vpd_filter_notes dst
USING (
SELECT 'ADMIN' function_owner,
'HMM_LEAVE_VPD_FILTER' function_name,
'Builds self, managed-team, and administrator leave predicates from HMM roles and access rules.'
description
FROM dual
) src
ON (
dst.function_owner = src.function_owner
AND dst.function_name = src.function_name
)
WHEN MATCHED THEN UPDATE SET
dst.description = src.description,
dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (
function_owner, function_name, description, updated_at
) VALUES (
src.function_owner, src.function_name, src.description, SYSTIMESTAMP
);
MERGE INTO hmm_access_vpd_policy_notes dst
USING (
SELECT 'ADMIN' object_owner,
object_name,
'HMM_LEAVE_SCOPE_POLICY' policy_name,
'Restricts leave rows to self, direct reports, or explicit HR administrator access.'
description
FROM (
SELECT 'HMM_LEAVE_BALANCES' object_name FROM dual
UNION ALL
SELECT 'HMM_LEAVE_REQUESTS' FROM dual
)
) src
ON (
dst.object_owner = src.object_owner
AND dst.object_name = src.object_name
AND dst.policy_name = src.policy_name
)
WHEN MATCHED THEN UPDATE SET
dst.description = src.description,
dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (
object_owner, object_name, policy_name, description, updated_at
) VALUES (
src.object_owner, src.object_name, src.policy_name,
src.description, SYSTIMESTAMP
);
COMMIT;
PROMPT === 8. Deployment inventory ===
SELECT object_name, object_type, status
FROM user_objects
WHERE object_name IN (
'HMM_ACCESS_CTX',
'HMM_ACCESS_CTX_PKG',
'HMM_LEAVE_VPD_FILTER'
)
ORDER BY object_name, object_type;
SELECT object_name, policy_name, function, sel, enable, policy_type
FROM all_policies
WHERE object_owner = 'ADMIN'
AND object_name IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS')
AND policy_name = 'HMM_LEAVE_SCOPE_POLICY'
ORDER BY object_name;
SELECT e.employee_code, r.role_name
FROM hmm_employee_access_roles er
JOIN hmm_hr_employees e ON e.employee_id = er.employee_id
JOIN hmm_access_roles r ON r.role_id = er.role_id
WHERE r.role_name IN ('HMM_HR_VIEWER', 'HMM_HR_MANAGER')
ORDER BY e.employee_code, r.role_name;
SELECT r.role_name,
p.target_name,
pr.rule_column,
pr.rule_type
FROM hmm_access_permissions p
JOIN hmm_access_roles r ON r.role_id = p.role_id
JOIN hmm_access_permission_rules pr ON pr.perm_id = p.perm_id
WHERE p.target_name IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS')
ORDER BY r.role_name, p.target_name;
PROMPT === HMM leave team VPD ready ===

View File

@@ -1,159 +0,0 @@
-- #740 HMM MCP VPD execution boundary
-- Run as ADMIN with SQLcl. The password is prompted without echo and is used
-- only when CB_ORDS does not already exist.
set define on verify off echo off feedback on serveroutput on
whenever sqlerror exit sql.sqlcode rollback
accept hmm_cb_ords_password char prompt 'CB_ORDS initial password: ' hide
declare
v_user_count number;
v_password varchar2(4000) := q'~&hmm_cb_ords_password~';
begin
select count(*)
into v_user_count
from dba_users
where username = 'CB_ORDS';
if v_user_count = 0 then
if length(v_password) < 16
or instr(v_password, '"') > 0
or instr(v_password, chr(10)) > 0
or instr(v_password, chr(13)) > 0
or not regexp_like(v_password, '[A-Z]')
or not regexp_like(v_password, '[a-z]')
or not regexp_like(v_password, '[0-9]')
or not regexp_like(v_password, '[^A-Za-z0-9]') then
raise_application_error(
-20001,
'CB_ORDS password must be 16+ chars with upper/lower/digit/special and no quote/newline.');
end if;
execute immediate
'create user CB_ORDS identified by "' || v_password || '" '
|| 'default tablespace DATA temporary tablespace TEMP quota 0 on DATA';
dbms_output.put_line('Created CB_ORDS.');
else
dbms_output.put_line('CB_ORDS already exists; password was not changed.');
end if;
end;
/
alter user CB_ORDS account unlock;
grant create session to CB_ORDS;
create or replace package CB_ORDS_HANDLER_PKG
authid definer
as
procedure SET_VPD_CONTEXT(p_authorization in varchar2);
procedure CLEAR_VPD_CONTEXT;
end CB_ORDS_HANDLER_PKG;
/
create or replace package body CB_ORDS_HANDLER_PKG
as
c_bearer_prefix constant varchar2(7) := 'Bearer ';
procedure CLEAR_VPD_CONTEXT
is
begin
HMM_ACCESS_CTX_PKG.CLEAR_USER;
dbms_session.clear_identifier;
end CLEAR_VPD_CONTEXT;
procedure SET_VPD_CONTEXT(p_authorization in varchar2)
is
v_authorization varchar2(2048) := trim(p_authorization);
v_token varchar2(512);
begin
CLEAR_VPD_CONTEXT;
if v_authorization is null
or length(v_authorization) > 2048
or upper(substr(v_authorization, 1, length(c_bearer_prefix)))
<> upper(c_bearer_prefix) then
raise_application_error(-20002, 'A valid Bearer authorization header is required.');
end if;
v_token := trim(substr(v_authorization, length(c_bearer_prefix) + 1));
if v_token is null or length(v_token) > 512 then
raise_application_error(-20003, 'A valid Bearer token is required.');
end if;
HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(v_token);
dbms_session.set_identifier(
sys_context('HMM_ACCESS_CTX', 'EMPLOYEE_CODE'));
exception
when others then
CLEAR_VPD_CONTEXT;
raise;
end SET_VPD_CONTEXT;
end CB_ORDS_HANDLER_PKG;
/
grant execute on CB_ORDS_HANDLER_PKG to CB_ORDS;
grant select on HMM_ORG_TEAMS to CB_ORDS;
grant select on HMM_HR_EMPLOYEES to CB_ORDS;
grant select on HMM_LEAVE_BALANCES to CB_ORDS;
grant select on HMM_LEAVE_REQUESTS to CB_ORDS;
grant select on HMM_ATTENDANCE_DAILY to CB_ORDS;
grant select on HMM_HR_TERMS to CB_ORDS;
create or replace synonym CB_ORDS.CB_ORDS_HANDLER_PKG
for ADMIN.CB_ORDS_HANDLER_PKG;
create or replace synonym CB_ORDS.HMM_ORG_TEAMS
for ADMIN.HMM_ORG_TEAMS;
create or replace synonym CB_ORDS.HMM_HR_EMPLOYEES
for ADMIN.HMM_HR_EMPLOYEES;
create or replace synonym CB_ORDS.HMM_LEAVE_BALANCES
for ADMIN.HMM_LEAVE_BALANCES;
create or replace synonym CB_ORDS.HMM_LEAVE_REQUESTS
for ADMIN.HMM_LEAVE_REQUESTS;
create or replace synonym CB_ORDS.HMM_ATTENDANCE_DAILY
for ADMIN.HMM_ATTENDANCE_DAILY;
create or replace synonym CB_ORDS.HMM_HR_TERMS
for ADMIN.HMM_HR_TERMS;
declare
v_exempt_count number;
v_policy_count number;
begin
select count(*)
into v_exempt_count
from dba_sys_privs
where grantee = 'CB_ORDS'
and privilege = 'EXEMPT ACCESS POLICY';
if v_exempt_count <> 0 then
raise_application_error(-20004, 'CB_ORDS must not have EXEMPT ACCESS POLICY.');
end if;
select count(*)
into v_policy_count
from dba_policies
where object_owner = 'ADMIN'
and object_name in ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS')
and policy_name = 'HMM_LEAVE_SCOPE_POLICY'
and enable = 'YES'
and sel = 'YES';
if v_policy_count <> 2 then
raise_application_error(-20005, 'Both HMM leave VPD policies must be enabled.');
end if;
end;
/
select username, account_status
from dba_users
where username = 'CB_ORDS';
select privilege
from dba_sys_privs
where grantee = 'CB_ORDS'
order by privilege;
select object_name, object_type, status
from dba_objects
where owner = 'ADMIN'
and object_name = 'CB_ORDS_HANDLER_PKG'
order by object_type;
undefine hmm_cb_ords_password
prompt HMM MCP VPD runtime boundary is ready.

View File

@@ -1,32 +0,0 @@
-- ============================================================
-- HMM ADB -> AWS RDS PostgreSQL federation demo
-- Step 0: precheck
-- Run in Database Actions SQL Worksheet as ADMIN.
-- Redmine: #730
-- ============================================================
SELECT
USER AS session_user,
SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS current_schema,
SYS_CONTEXT('USERENV', 'DB_NAME') AS database_name
FROM dual;
SELECT credential_name, username
FROM all_credentials
WHERE credential_name = 'HMM_RDS_PG_CRED';
SELECT db_link, username, host, created
FROM user_db_links
WHERE db_link = 'HMM_RDS_PG_LINK';
SELECT object_name, object_type, status
FROM user_objects
WHERE object_name IN (
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
)
ORDER BY object_name;
-- First recording: the last three queries should return no rows.
-- If objects already exist, run 80_hmm_rds_pg_99_cleanup.sql first.

View File

@@ -1,20 +0,0 @@
-- ============================================================
-- Step 1: create the PostgreSQL credential.
-- Run only this statement after replacing <RDS_PASSWORD> in the
-- Worksheet. Never save the actual password in this file.
-- Redmine: #730
-- ============================================================
BEGIN
DBMS_CLOUD.CREATE_CREDENTIAL(
credential_name => 'HMM_RDS_PG_CRED',
username => 'postgres',
password => '<RDS_PASSWORD>'
);
END;
/
-- Passwords are never returned by ALL_CREDENTIALS.
SELECT credential_name, username
FROM all_credentials
WHERE credential_name = 'HMM_RDS_PG_CRED';

View File

@@ -1,61 +0,0 @@
-- ============================================================
-- Step 2: register and verify the host-specific ACL.
-- Run as ADMIN.
--
-- Important:
-- Oracle-managed heterogeneous Database Link traffic uses the
-- managed gateway. This ACL records SQL-user host permissions;
-- it does not replace the AWS RDS Security Group rule.
-- Redmine: #730
-- ============================================================
-- DNS resolution privilege must not have a port range.
BEGIN
DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
host => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com',
ace => XS$ACE_TYPE(
privilege_list => XS$NAME_LIST('resolve'),
principal_name => SYS_CONTEXT('USERENV', 'SESSION_USER'),
principal_type => XS_ACL.PTYPE_DB
)
);
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -24243 THEN
RAISE;
END IF;
END;
/
-- Permit only the PostgreSQL service port.
BEGIN
DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
host => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com',
lower_port => 5432,
upper_port => 5432,
ace => XS$ACE_TYPE(
privilege_list => XS$NAME_LIST('connect'),
principal_name => SYS_CONTEXT('USERENV', 'SESSION_USER'),
principal_type => XS_ACL.PTYPE_DB
)
);
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -24243 THEN
RAISE;
END IF;
END;
/
SELECT
host,
lower_port,
upper_port,
principal,
privilege,
grant_type
FROM dba_host_aces
WHERE host = 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com'
AND principal = USER
AND privilege IN ('resolve', 'connect')
ORDER BY privilege, lower_port;

View File

@@ -1,24 +0,0 @@
-- ============================================================
-- Step 3: create an Oracle-managed PostgreSQL Database Link.
-- Based on FY26 internal training, section 3.2.3.
-- Run as ADMIN.
-- Redmine: #730
-- ============================================================
BEGIN
DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK(
db_link_name => 'HMM_RDS_PG_LINK',
hostname => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com',
port => 5432,
service_name => 'postgres',
credential_name => 'HMM_RDS_PG_CRED',
ssl_server_cert_dn => NULL,
private_target => FALSE,
gateway_params => JSON_OBJECT('db_type' VALUE 'postgres')
);
END;
/
SELECT db_link, username, host, created
FROM user_db_links
WHERE db_link = 'HMM_RDS_PG_LINK';

View File

@@ -1,34 +0,0 @@
-- ============================================================
-- Step 4: query PostgreSQL directly through the Database Link.
-- PostgreSQL schema, table, view, and column identifiers are
-- lower-case and must be double quoted.
-- Redmine: #730
-- ============================================================
SELECT COUNT(*) AS carrier_count
FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK;
SELECT
COUNT(*) AS performance_count,
MIN("performance_month") AS first_month,
MAX("performance_month") AS latest_month
FROM "hmm_demo"."carrier_monthly_performance"@HMM_RDS_PG_LINK;
SELECT
"carrier_code",
"carrier_name",
"service_region",
"performance_tier"
FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK
ORDER BY "carrier_code";
SELECT
"carrier_code",
"carrier_name",
"performance_month",
"shipped_teu",
"revenue_usd",
"schedule_reliability_pct",
"risk_level"
FROM "hmm_demo"."carrier_performance_latest_v"@HMM_RDS_PG_LINK
ORDER BY "carrier_code";

View File

@@ -1,109 +0,0 @@
-- ============================================================
-- Step 5: create local Oracle Views and Select AI metadata.
-- Explicit aliases normalize PostgreSQL lower-case identifiers
-- into ordinary Oracle identifiers.
-- Redmine: #730
-- ============================================================
CREATE OR REPLACE VIEW HMM_RDS_CARRIERS_V AS
SELECT
"carrier_code" AS carrier_code,
"carrier_name" AS carrier_name,
"country_code" AS country_code,
"alliance_name" AS alliance_name,
"service_region" AS service_region,
"performance_tier" AS performance_tier
FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK;
COMMENT ON TABLE HMM_RDS_CARRIERS_V IS
'HMM federation demo fictional carrier master. Primary Key: CARRIER_CODE. Referenced by HMM_RDS_CARRIER_PERF_V(CARRIER_CODE).';
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.CARRIER_CODE IS
'Primary Key. Fictional carrier identifier such as C001.';
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.CARRIER_NAME IS
'Fictional carrier display name; not an actual carrier.';
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.COUNTRY_CODE IS
'Two-letter demo country code.';
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.ALLIANCE_NAME IS
'Fictional carrier cooperation network.';
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.SERVICE_REGION IS
'Primary service region for the demo carrier.';
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.PERFORMANCE_TIER IS
'Demo portfolio tier: CORE, GROWTH, or WATCH.';
CREATE OR REPLACE VIEW HMM_RDS_CARRIER_PERF_V AS
SELECT
"carrier_code" AS carrier_code,
"performance_month" AS performance_month,
"contracted_teu" AS contracted_teu,
"shipped_teu" AS shipped_teu,
"revenue_usd" AS revenue_usd,
"gross_margin_usd" AS gross_margin_usd,
"schedule_reliability_pct" AS schedule_reliability_pct,
"vessel_utilization_pct" AS vessel_utilization_pct,
"claim_rate_pct" AS claim_rate_pct,
"yoy_growth_pct" AS yoy_growth_pct,
"risk_level" AS risk_level
FROM "hmm_demo"."carrier_monthly_performance"@HMM_RDS_PG_LINK;
COMMENT ON TABLE HMM_RDS_CARRIER_PERF_V IS
'HMM federation demo monthly carrier KPI. Composite Primary Key: CARRIER_CODE and PERFORMANCE_MONTH. Foreign Key: CARRIER_CODE references HMM_RDS_CARRIERS_V(CARRIER_CODE). All values are fictional.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CARRIER_CODE IS
'Foreign Key referencing HMM_RDS_CARRIERS_V(CARRIER_CODE).';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.PERFORMANCE_MONTH IS
'First calendar day of the KPI month; part of the composite Primary Key.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CONTRACTED_TEU IS
'Contracted container volume in TEU.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.SHIPPED_TEU IS
'Actually shipped container volume in TEU.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.REVENUE_USD IS
'Fictional monthly revenue in USD.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.GROSS_MARGIN_USD IS
'Fictional monthly gross margin in USD; negative values indicate a loss.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.SCHEDULE_RELIABILITY_PCT IS
'Schedule reliability percentage from 0 through 100.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.VESSEL_UTILIZATION_PCT IS
'Vessel capacity utilization percentage from 0 through 100.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CLAIM_RATE_PCT IS
'Cargo claim rate percentage from 0 through 100.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.YOY_GROWTH_PCT IS
'Year-over-year performance growth percentage.';
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.RISK_LEVEL IS
'Derived demo risk category: GREEN, AMBER, or RED.';
CREATE OR REPLACE VIEW HMM_RDS_CARRIER_LATEST_V AS
SELECT
"carrier_code" AS carrier_code,
"carrier_name" AS carrier_name,
"country_code" AS country_code,
"alliance_name" AS alliance_name,
"service_region" AS service_region,
"performance_tier" AS performance_tier,
"performance_month" AS performance_month,
"contracted_teu" AS contracted_teu,
"shipped_teu" AS shipped_teu,
"revenue_usd" AS revenue_usd,
"gross_margin_usd" AS gross_margin_usd,
"schedule_reliability_pct" AS schedule_reliability_pct,
"vessel_utilization_pct" AS vessel_utilization_pct,
"claim_rate_pct" AS claim_rate_pct,
"yoy_growth_pct" AS yoy_growth_pct,
"risk_level" AS risk_level
FROM "hmm_demo"."carrier_performance_latest_v"@HMM_RDS_PG_LINK;
COMMENT ON TABLE HMM_RDS_CARRIER_LATEST_V IS
'Latest fictional monthly KPI for each HMM federation demo carrier. Primary Key: CARRIER_CODE.';
COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE IS
'Primary Key for the latest carrier KPI row.';
COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.PERFORMANCE_MONTH IS
'Latest KPI month available in the PostgreSQL source.';
COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.RISK_LEVEL IS
'Latest derived risk category: GREEN, AMBER, or RED.';
SELECT object_name, object_type, status
FROM user_objects
WHERE object_name IN (
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
)
ORDER BY object_name;

View File

@@ -1,86 +0,0 @@
-- ============================================================
-- Step 6: verify the local federation Views.
-- Expected: carrier/performance/latest = 8/144/8.
-- Redmine: #730
-- ============================================================
DECLARE
v_carrier_count PLS_INTEGER;
v_performance_count PLS_INTEGER;
v_latest_count PLS_INTEGER;
v_risk_count PLS_INTEGER;
BEGIN
SELECT COUNT(*) INTO v_carrier_count
FROM HMM_RDS_CARRIERS_V;
SELECT COUNT(*) INTO v_performance_count
FROM HMM_RDS_CARRIER_PERF_V;
SELECT COUNT(*), COUNT(DISTINCT risk_level)
INTO v_latest_count, v_risk_count
FROM HMM_RDS_CARRIER_LATEST_V;
IF v_carrier_count != 8 THEN
RAISE_APPLICATION_ERROR(
-20001,
'Expected 8 carriers, found ' || v_carrier_count
);
END IF;
IF v_performance_count != 144 THEN
RAISE_APPLICATION_ERROR(
-20002,
'Expected 144 performance rows, found ' || v_performance_count
);
END IF;
IF v_latest_count != 8 OR v_risk_count != 3 THEN
RAISE_APPLICATION_ERROR(
-20003,
'Expected 8 latest rows and 3 risk levels, found '
|| v_latest_count || ' rows and ' || v_risk_count || ' levels'
);
END IF;
END;
/
SELECT
(SELECT COUNT(*) FROM HMM_RDS_CARRIERS_V) AS carrier_count,
(SELECT COUNT(*) FROM HMM_RDS_CARRIER_PERF_V) AS performance_count,
(SELECT COUNT(*) FROM HMM_RDS_CARRIER_LATEST_V) AS latest_count
FROM dual;
SELECT
MIN(performance_month) AS first_month,
MAX(performance_month) AS latest_month,
COUNT(DISTINCT carrier_code) AS carrier_count
FROM HMM_RDS_CARRIER_PERF_V;
SELECT risk_level, COUNT(*) AS carrier_count
FROM HMM_RDS_CARRIER_LATEST_V
GROUP BY risk_level
ORDER BY risk_level;
SELECT
carrier_code,
carrier_name,
performance_month,
shipped_teu,
revenue_usd,
gross_margin_usd,
schedule_reliability_pct,
claim_rate_pct,
risk_level
FROM HMM_RDS_CARRIER_LATEST_V
ORDER BY carrier_code;
SELECT
table_name,
comments
FROM user_tab_comments
WHERE table_name IN (
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
)
ORDER BY table_name;

View File

@@ -1,63 +0,0 @@
-- ============================================================
-- Optional pre-recording reset.
-- Drops only the three local Views, Database Link, and
-- Credential owned by this demo. Host ACL entries are retained.
-- Redmine: #730
-- ============================================================
DECLARE
v_count PLS_INTEGER;
BEGIN
FOR object_row IN (
SELECT object_name
FROM user_objects
WHERE object_type = 'VIEW'
AND object_name IN (
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
)
ORDER BY CASE object_name
WHEN 'HMM_RDS_CARRIER_LATEST_V' THEN 1
WHEN 'HMM_RDS_CARRIER_PERF_V' THEN 2
ELSE 3
END
) LOOP
EXECUTE IMMEDIATE 'DROP VIEW ' || object_row.object_name;
END LOOP;
SELECT COUNT(*) INTO v_count
FROM user_db_links
WHERE db_link = 'HMM_RDS_PG_LINK';
IF v_count = 1 THEN
EXECUTE IMMEDIATE 'DROP DATABASE LINK HMM_RDS_PG_LINK';
END IF;
SELECT COUNT(*) INTO v_count
FROM all_credentials
WHERE credential_name = 'HMM_RDS_PG_CRED';
IF v_count = 1 THEN
DBMS_CLOUD.DROP_CREDENTIAL(
credential_name => 'HMM_RDS_PG_CRED'
);
END IF;
END;
/
SELECT credential_name, username
FROM all_credentials
WHERE credential_name = 'HMM_RDS_PG_CRED';
SELECT db_link, username, host
FROM user_db_links
WHERE db_link = 'HMM_RDS_PG_LINK';
SELECT object_name, object_type
FROM user_objects
WHERE object_name IN (
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
);

View File

@@ -1,209 +0,0 @@
-- ============================================================
-- HMM carrier assignments using existing access-group tables.
--
-- Existing tables:
-- HMM_HR_EMPLOYEES
-- HMM_ACCESS_GROUPS
-- HMM_ACCESS_GROUP_MEMBERS
--
-- No new assignment table is created.
-- Korean values are reconstructed from UTF-8 base64 so SQLcl
-- cannot corrupt the literals.
-- Redmine: #730
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
SET SERVEROUTPUT ON
MERGE INTO hmm_access_groups dst
USING (
SELECT
base.max_group_id + carrier.seq_no AS group_id,
'CARRIER_' || carrier.carrier_code AS group_code,
carrier.carrier_code || ' ' ||
utl_i18n.raw_to_char(
utl_encode.base64_decode(
utl_raw.cast_to_raw('7ISg7IKsIOuLtOuLuSDqt7jro7k=')
),
'AL32UTF8'
) AS group_name,
utl_i18n.raw_to_char(
utl_encode.base64_decode(
utl_raw.cast_to_raw(
'642w66qo7JqpIOyEoOyCrCDsoJHqt7wg67CPIOuLtOuLueyekCDqt7jro7k='
)
),
'AL32UTF8'
) || ' (' || carrier.carrier_code || ')' AS description
FROM (
SELECT 1 seq_no, 'C001' carrier_code FROM dual
UNION ALL SELECT 2, 'C002' FROM dual
UNION ALL SELECT 3, 'C003' FROM dual
UNION ALL SELECT 4, 'C004' FROM dual
UNION ALL SELECT 5, 'C005' FROM dual
UNION ALL SELECT 6, 'C006' FROM dual
UNION ALL SELECT 7, 'C007' FROM dual
UNION ALL SELECT 8, 'C008' FROM dual
) carrier
CROSS JOIN (
SELECT NVL(MAX(group_id), 0) max_group_id
FROM hmm_access_groups
) base
) src
ON (dst.group_code = src.group_code)
WHEN MATCHED THEN UPDATE SET
dst.group_name = src.group_name,
dst.description = src.description,
dst.active_yn = 'Y',
dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (
group_id,
group_code,
group_name,
description,
active_yn,
created_at,
updated_at
) VALUES (
src.group_id,
src.group_code,
src.group_name,
src.description,
'Y',
SYSTIMESTAMP,
SYSTIMESTAMP
);
MERGE INTO hmm_access_group_members dst
USING (
SELECT
access_group.group_id,
employee.employee_id
FROM (
SELECT 'E1002' employee_code, 'CARRIER_C001' group_code FROM dual
UNION ALL SELECT 'E1002', 'CARRIER_C002' FROM dual
UNION ALL SELECT 'E1003', 'CARRIER_C003' FROM dual
UNION ALL SELECT 'E1004', 'CARRIER_C004' FROM dual
UNION ALL SELECT 'E1005', 'CARRIER_C005' FROM dual
UNION ALL SELECT 'E1006', 'CARRIER_C006' FROM dual
UNION ALL SELECT 'E1006', 'CARRIER_C007' FROM dual
UNION ALL SELECT 'E1007', 'CARRIER_C008' FROM dual
) assignment
JOIN hmm_hr_employees employee
ON employee.employee_code = assignment.employee_code
AND employee.employment_status = 'ACTIVE'
JOIN hmm_access_groups access_group
ON access_group.group_code = assignment.group_code
AND access_group.active_yn = 'Y'
) src
ON (
dst.group_id = src.group_id
AND dst.employee_id = src.employee_id
)
WHEN NOT MATCHED THEN INSERT (
group_id,
employee_id,
created_at
) VALUES (
src.group_id,
src.employee_id,
SYSTIMESTAMP
);
CREATE OR REPLACE VIEW hmm_carrier_assignments_v AS
SELECT
employee.employee_id,
employee.employee_code,
employee.full_name AS employee_name,
employee.job_title,
employee.team_id,
employee.manager_employee_id,
manager.employee_code AS manager_employee_code,
manager.full_name AS manager_name,
SUBSTR(access_group.group_code, LENGTH('CARRIER_') + 1) AS carrier_code,
access_group.group_code,
access_group.group_name
FROM hmm_access_group_members membership
JOIN hmm_access_groups access_group
ON access_group.group_id = membership.group_id
AND access_group.active_yn = 'Y'
JOIN hmm_hr_employees employee
ON employee.employee_id = membership.employee_id
AND employee.employment_status = 'ACTIVE'
LEFT JOIN hmm_hr_employees manager
ON manager.employee_id = employee.manager_employee_id
WHERE access_group.group_code LIKE 'CARRIER_C%';
COMMENT ON TABLE hmm_carrier_assignments_v IS
'ADB employee-to-carrier assignments from HMM_HR_EMPLOYEES, HMM_ACCESS_GROUPS, and HMM_ACCESS_GROUP_MEMBERS. Join relationship: HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE joins HMM_RDS_CARRIERS_V.CARRIER_CODE, HMM_RDS_CARRIER_PERF_V.CARRIER_CODE, and HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.';
COMMENT ON COLUMN hmm_carrier_assignments_v.EMPLOYEE_CODE IS
'HMM employee code sourced from HMM_HR_EMPLOYEES.EMPLOYEE_CODE. Example: E1002.';
COMMENT ON COLUMN hmm_carrier_assignments_v.MANAGER_EMPLOYEE_CODE IS
'Direct manager employee code sourced from HMM_HR_EMPLOYEES. Use E1001 to identify the HMM demo manager team.';
COMMENT ON COLUMN hmm_carrier_assignments_v.CARRIER_CODE IS
'Join Key and Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).';
COMMENT ON COLUMN hmm_carrier_assignments_v.GROUP_CODE IS
'Existing access-group code in the form CARRIER_C001.';
COMMIT;
DECLARE
v_group_count PLS_INTEGER;
v_member_count PLS_INTEGER;
v_assignment_count PLS_INTEGER;
v_federated_count PLS_INTEGER;
BEGIN
SELECT COUNT(*)
INTO v_group_count
FROM hmm_access_groups
WHERE group_code BETWEEN 'CARRIER_C001' AND 'CARRIER_C008'
AND active_yn = 'Y';
SELECT COUNT(*)
INTO v_member_count
FROM hmm_access_group_members membership
JOIN hmm_access_groups access_group
ON access_group.group_id = membership.group_id
WHERE access_group.group_code BETWEEN 'CARRIER_C001' AND 'CARRIER_C008';
SELECT COUNT(*)
INTO v_assignment_count
FROM hmm_carrier_assignments_v;
SELECT COUNT(*)
INTO v_federated_count
FROM hmm_carrier_assignments_v assignment
JOIN hmm_rds_carrier_latest_v performance
ON performance.carrier_code = assignment.carrier_code;
IF v_group_count != 8
OR v_member_count != 8
OR v_assignment_count != 8
OR v_federated_count != 8 THEN
RAISE_APPLICATION_ERROR(
-20081,
'Expected group/member/assignment/federated counts 8/8/8/8; found '
|| v_group_count || '/'
|| v_member_count || '/'
|| v_assignment_count || '/'
|| v_federated_count
);
END IF;
END;
/
SELECT
assignment.employee_code,
assignment.employee_name,
assignment.manager_employee_code,
assignment.carrier_code,
performance.carrier_name,
performance.performance_month,
performance.revenue_usd,
performance.risk_level
FROM hmm_carrier_assignments_v assignment
JOIN hmm_rds_carrier_latest_v performance
ON performance.carrier_code = assignment.carrier_code
ORDER BY assignment.employee_code, assignment.carrier_code;

View File

@@ -1,67 +0,0 @@
-- ============================================================
-- Explicit Select AI relationship metadata for ADB + RDS Views.
-- Redmine: #730
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
COMMENT ON TABLE hmm_carrier_assignments_v IS
'ADB employee-to-carrier assignments from HMM_HR_EMPLOYEES, HMM_ACCESS_GROUPS, and HMM_ACCESS_GROUP_MEMBERS. Join relationship: HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE joins HMM_RDS_CARRIERS_V.CARRIER_CODE, HMM_RDS_CARRIER_PERF_V.CARRIER_CODE, and HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.';
COMMENT ON COLUMN hmm_carrier_assignments_v.EMPLOYEE_CODE IS
'HMM employee code sourced from HMM_HR_EMPLOYEES.EMPLOYEE_CODE. Example: E1002.';
COMMENT ON COLUMN hmm_carrier_assignments_v.MANAGER_EMPLOYEE_CODE IS
'Direct manager employee code sourced from HMM_HR_EMPLOYEES. Use E1001 to identify the HMM demo manager team.';
COMMENT ON COLUMN hmm_carrier_assignments_v.CARRIER_CODE IS
'Join Key and Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).';
COMMENT ON TABLE hmm_rds_carriers_v IS
'PostgreSQL fictional carrier master exposed through HMM_RDS_PG_LINK. Primary Key: CARRIER_CODE. Join relationship: HMM_RDS_CARRIERS_V.CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE and HMM_RDS_CARRIER_PERF_V.CARRIER_CODE.';
COMMENT ON COLUMN hmm_rds_carriers_v.CARRIER_CODE IS
'Primary Key. Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).';
COMMENT ON TABLE hmm_rds_carrier_perf_v IS
'PostgreSQL monthly carrier KPI exposed through HMM_RDS_PG_LINK. Composite Primary Key: CARRIER_CODE and PERFORMANCE_MONTH. Foreign Key: CARRIER_CODE references HMM_RDS_CARRIERS_V(CARRIER_CODE). Join relationship: CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE.';
COMMENT ON COLUMN hmm_rds_carrier_perf_v.CARRIER_CODE IS
'Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE). Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE).';
COMMENT ON COLUMN hmm_rds_carrier_perf_v.PERFORMANCE_MONTH IS
'Part of the composite Primary Key with CARRIER_CODE. First calendar day of the KPI month.';
COMMENT ON TABLE hmm_rds_carrier_latest_v IS
'Latest PostgreSQL KPI for each fictional carrier exposed through HMM_RDS_PG_LINK. Primary Key: CARRIER_CODE. Join relationship: HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE and HMM_RDS_CARRIERS_V.CARRIER_CODE.';
COMMENT ON COLUMN hmm_rds_carrier_latest_v.CARRIER_CODE IS
'Primary Key. Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE) and HMM_RDS_CARRIERS_V(CARRIER_CODE).';
SELECT table_name, comments
FROM user_tab_comments
WHERE table_name IN (
'HMM_CARRIER_ASSIGNMENTS_V',
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
)
ORDER BY table_name;
SELECT table_name, column_name, comments
FROM user_col_comments
WHERE table_name IN (
'HMM_CARRIER_ASSIGNMENTS_V',
'HMM_RDS_CARRIERS_V',
'HMM_RDS_CARRIER_PERF_V',
'HMM_RDS_CARRIER_LATEST_V'
)
AND column_name IN (
'EMPLOYEE_CODE',
'MANAGER_EMPLOYEE_CODE',
'CARRIER_CODE',
'PERFORMANCE_MONTH'
)
ORDER BY table_name, column_name;

View File

@@ -1,300 +0,0 @@
-- ============================================================
-- HMM demo carrier performance source data for PostgreSQL.
--
-- Target: AWS RDS for PostgreSQL, database postgres
-- Schema: hmm_demo
-- Redmine: #729
--
-- This script is idempotent. It owns only C001..C008 and their
-- 2025-02 through 2026-07 monthly demo rows. It never stores an
-- RDS endpoint, password, or certificate path.
--
-- Example:
-- PGPASSWORD="$PG_PASSWORD" psql \
-- "host=$PG_HOST port=5432 dbname=postgres user=$PG_USER \
-- sslmode=verify-full sslrootcert=$PG_SSL_ROOT_CERT" \
-- -X -f database/source/postgres_hmm_carrier_performance.sql
-- ============================================================
\set ON_ERROR_STOP on
\echo === HMM carrier performance demo: begin ===
BEGIN;
CREATE SCHEMA IF NOT EXISTS hmm_demo AUTHORIZATION postgres;
COMMENT ON SCHEMA hmm_demo IS
'HMM federation demonstration data. All carrier names and KPI values are fictional.';
REVOKE CREATE ON SCHEMA hmm_demo FROM PUBLIC;
CREATE TABLE IF NOT EXISTS hmm_demo.carriers (
carrier_code VARCHAR(10) PRIMARY KEY,
carrier_name VARCHAR(120) NOT NULL UNIQUE,
country_code CHAR(2) NOT NULL CHECK (country_code ~ '^[A-Z]{2}$'),
alliance_name VARCHAR(80),
service_region VARCHAR(40) NOT NULL,
performance_tier VARCHAR(10) NOT NULL
CHECK (performance_tier IN ('CORE', 'GROWTH', 'WATCH')),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE hmm_demo.carriers IS
'Fictional carrier master data for the HMM ADB and PostgreSQL federation demo.';
COMMENT ON COLUMN hmm_demo.carriers.carrier_code IS 'Fictional carrier identifier.';
COMMENT ON COLUMN hmm_demo.carriers.performance_tier IS
'Demo portfolio tier: CORE, GROWTH, or WATCH.';
CREATE TABLE IF NOT EXISTS hmm_demo.carrier_monthly_performance (
carrier_code VARCHAR(10) NOT NULL
REFERENCES hmm_demo.carriers (carrier_code),
performance_month DATE NOT NULL
CHECK (performance_month = date_trunc('month', performance_month)::DATE),
contracted_teu INTEGER NOT NULL CHECK (contracted_teu >= 0),
shipped_teu INTEGER NOT NULL CHECK (shipped_teu >= 0),
revenue_usd NUMERIC(16,2) NOT NULL CHECK (revenue_usd >= 0),
gross_margin_usd NUMERIC(16,2) NOT NULL,
schedule_reliability_pct NUMERIC(5,2) NOT NULL
CHECK (schedule_reliability_pct BETWEEN 0 AND 100),
vessel_utilization_pct NUMERIC(5,2) NOT NULL
CHECK (vessel_utilization_pct BETWEEN 0 AND 100),
claim_rate_pct NUMERIC(5,2) NOT NULL
CHECK (claim_rate_pct BETWEEN 0 AND 100),
yoy_growth_pct NUMERIC(7,2) NOT NULL,
risk_level VARCHAR(10) NOT NULL
CHECK (risk_level IN ('GREEN', 'AMBER', 'RED')),
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (carrier_code, performance_month)
);
COMMENT ON TABLE hmm_demo.carrier_monthly_performance IS
'Deterministic fictional monthly carrier KPIs for 2025-02 through 2026-07.';
COMMENT ON COLUMN hmm_demo.carrier_monthly_performance.performance_month IS
'First calendar day of the KPI month.';
COMMENT ON COLUMN hmm_demo.carrier_monthly_performance.risk_level IS
'Derived demo risk level based on margin, reliability, and claim rate.';
CREATE INDEX IF NOT EXISTS ix_carrier_perf_month
ON hmm_demo.carrier_monthly_performance (performance_month, carrier_code);
CREATE INDEX IF NOT EXISTS ix_carrier_perf_risk
ON hmm_demo.carrier_monthly_performance (risk_level, performance_month);
INSERT INTO hmm_demo.carriers (
carrier_code,
carrier_name,
country_code,
alliance_name,
service_region,
performance_tier,
active
) VALUES
('C001', 'Bluewave Maritime', 'SG', 'North Pacific Network', 'ASIA-PACIFIC', 'CORE', TRUE),
('C002', 'Pacific Star Lines', 'JP', 'East Ocean Connect', 'ASIA-PACIFIC', 'CORE', TRUE),
('C003', 'Korea Ocean Link', 'KR', 'East Ocean Connect', 'ASIA-PACIFIC', 'GROWTH', TRUE),
('C004', 'Nordic Seaways', 'DK', 'Northern Trade Group', 'EUROPE', 'CORE', TRUE),
('C005', 'Orient Bridge Shipping', 'TW', 'North Pacific Network', 'ASIA-PACIFIC', 'WATCH', TRUE),
('C006', 'Atlantic Merchant Line', 'DE', 'Atlantic Cargo Forum', 'EUROPE-AMERICAS', 'WATCH', TRUE),
('C007', 'Global Horizon Cargo', 'US', 'Atlantic Cargo Forum', 'AMERICAS', 'GROWTH', TRUE),
('C008', 'Southern Cross Marine', 'FR', 'Southern Ocean Group', 'OCEANIA', 'WATCH', TRUE)
ON CONFLICT (carrier_code) DO UPDATE SET
carrier_name = EXCLUDED.carrier_name,
country_code = EXCLUDED.country_code,
alliance_name = EXCLUDED.alliance_name,
service_region = EXCLUDED.service_region,
performance_tier = EXCLUDED.performance_tier,
active = EXCLUDED.active;
WITH month_series AS (
SELECT
month_index,
(DATE '2025-02-01' + make_interval(months => month_index))::DATE AS performance_month,
CASE month_index % 6
WHEN 0 THEN -1
WHEN 1 THEN 0
WHEN 2 THEN 1
WHEN 3 THEN 2
WHEN 4 THEN 1
ELSE -1
END::NUMERIC AS seasonal_factor
FROM generate_series(0, 17) AS series(month_index)
),
profiles (
carrier_code,
base_teu,
monthly_teu_step,
seasonal_teu,
utilization_base,
utilization_step,
revenue_per_teu,
revenue_step,
margin_pct_base,
margin_pct_step,
reliability_base,
reliability_step,
claim_rate_base,
claim_rate_step,
yoy_growth_base,
yoy_growth_step
) AS (
VALUES
('C001', 1800::NUMERIC, 25::NUMERIC, 80::NUMERIC, 91.5::NUMERIC, 0.10::NUMERIC, 1850::NUMERIC, 4::NUMERIC, 15.0::NUMERIC, 0.10::NUMERIC, 95.0::NUMERIC, 0.05::NUMERIC, 0.35::NUMERIC, -0.005::NUMERIC, 8.0::NUMERIC, 0.25::NUMERIC),
('C002', 1500::NUMERIC, 5::NUMERIC, 60::NUMERIC, 88.5::NUMERIC, 0.02::NUMERIC, 1780::NUMERIC, 3::NUMERIC, 12.0::NUMERIC, 0.00::NUMERIC, 92.5::NUMERIC, 0.00::NUMERIC, 0.45::NUMERIC, 0.005::NUMERIC, 3.0::NUMERIC, 0.05::NUMERIC),
('C003', 1050::NUMERIC, 15::NUMERIC, 50::NUMERIC, 83.0::NUMERIC, 0.20::NUMERIC, 1920::NUMERIC, 2::NUMERIC, 8.0::NUMERIC, 0.15::NUMERIC, 88.5::NUMERIC, 0.25::NUMERIC, 1.20::NUMERIC, -0.040::NUMERIC, -4.0::NUMERIC, 0.60::NUMERIC),
('C004', 1350::NUMERIC, 0::NUMERIC, 180::NUMERIC, 86.0::NUMERIC, 0.00::NUMERIC, 1810::NUMERIC, 3::NUMERIC, 10.0::NUMERIC, 0.00::NUMERIC, 93.0::NUMERIC, 0.00::NUMERIC, 0.55::NUMERIC, 0.010::NUMERIC, 1.0::NUMERIC, 0.10::NUMERIC),
('C005', 1300::NUMERIC, -20::NUMERIC, 70::NUMERIC, 88.0::NUMERIC, -0.10::NUMERIC, 1850::NUMERIC, -2::NUMERIC, 8.0::NUMERIC, -0.20::NUMERIC, 91.0::NUMERIC, -0.15::NUMERIC, 0.80::NUMERIC, 0.030::NUMERIC, -2.0::NUMERIC, -0.50::NUMERIC),
('C006', 1250::NUMERIC, -10::NUMERIC, 90::NUMERIC, 84.0::NUMERIC, -0.12::NUMERIC, 1800::NUMERIC, -3::NUMERIC, 6.0::NUMERIC, -0.15::NUMERIC, 87.0::NUMERIC, -0.25::NUMERIC, 1.50::NUMERIC, 0.050::NUMERIC, -5.0::NUMERIC, -0.70::NUMERIC),
('C007', 800::NUMERIC, 30::NUMERIC, 100::NUMERIC, 82.0::NUMERIC, 0.25::NUMERIC, 2050::NUMERIC, 5::NUMERIC, 9.0::NUMERIC, 0.18::NUMERIC, 90.5::NUMERIC, 0.15::NUMERIC, 0.70::NUMERIC, -0.020::NUMERIC, 12.0::NUMERIC, 0.50::NUMERIC),
('C008', 1150::NUMERIC, -25::NUMERIC, 60::NUMERIC, 80.0::NUMERIC, -0.15::NUMERIC, 1750::NUMERIC, -4::NUMERIC, 3.0::NUMERIC, -0.20::NUMERIC, 85.0::NUMERIC, -0.30::NUMERIC, 2.00::NUMERIC, 0.080::NUMERIC, -8.0::NUMERIC, -0.80::NUMERIC)
),
base_metrics AS (
SELECT
p.carrier_code,
m.performance_month,
ROUND(
p.base_teu
+ p.monthly_teu_step * m.month_index
+ p.seasonal_teu * m.seasonal_factor
)::INTEGER AS contracted_teu,
LEAST(
98.0::NUMERIC,
GREATEST(
60.0::NUMERIC,
p.utilization_base
+ p.utilization_step * m.month_index
+ m.seasonal_factor * 0.50
)
) AS utilization_pct,
p.revenue_per_teu + p.revenue_step * m.month_index AS unit_revenue,
p.margin_pct_base + p.margin_pct_step * m.month_index AS margin_pct,
LEAST(
99.5::NUMERIC,
GREATEST(
60.0::NUMERIC,
p.reliability_base
+ p.reliability_step * m.month_index
+ m.seasonal_factor * 0.20
)
) AS reliability_pct,
LEAST(
100.0::NUMERIC,
GREATEST(
0.0::NUMERIC,
p.claim_rate_base + p.claim_rate_step * m.month_index
)
) AS claim_pct,
p.yoy_growth_base + p.yoy_growth_step * m.month_index AS yoy_pct
FROM profiles p
CROSS JOIN month_series m
),
volume_metrics AS (
SELECT
b.*,
ROUND(b.contracted_teu * b.utilization_pct / 100.0)::INTEGER AS shipped_teu
FROM base_metrics b
),
financial_metrics AS (
SELECT
v.*,
ROUND(v.shipped_teu * v.unit_revenue, 2) AS revenue_usd
FROM volume_metrics v
),
final_metrics AS (
SELECT
f.*,
ROUND(f.revenue_usd * f.margin_pct / 100.0, 2) AS gross_margin_usd
FROM financial_metrics f
)
INSERT INTO hmm_demo.carrier_monthly_performance (
carrier_code,
performance_month,
contracted_teu,
shipped_teu,
revenue_usd,
gross_margin_usd,
schedule_reliability_pct,
vessel_utilization_pct,
claim_rate_pct,
yoy_growth_pct,
risk_level,
updated_at
)
SELECT
f.carrier_code,
f.performance_month,
f.contracted_teu,
f.shipped_teu,
f.revenue_usd,
f.gross_margin_usd,
ROUND(f.reliability_pct, 2),
ROUND(f.utilization_pct, 2),
ROUND(f.claim_pct, 2),
ROUND(f.yoy_pct, 2),
CASE
WHEN f.gross_margin_usd < 0
OR f.reliability_pct < 87
OR f.claim_pct >= 2 THEN 'RED'
WHEN f.margin_pct < 7
OR f.reliability_pct < 92
OR f.claim_pct >= 1 THEN 'AMBER'
ELSE 'GREEN'
END,
TIMESTAMPTZ '2026-07-24 00:00:00+00'
FROM final_metrics f
ON CONFLICT (carrier_code, performance_month) DO UPDATE SET
contracted_teu = EXCLUDED.contracted_teu,
shipped_teu = EXCLUDED.shipped_teu,
revenue_usd = EXCLUDED.revenue_usd,
gross_margin_usd = EXCLUDED.gross_margin_usd,
schedule_reliability_pct = EXCLUDED.schedule_reliability_pct,
vessel_utilization_pct = EXCLUDED.vessel_utilization_pct,
claim_rate_pct = EXCLUDED.claim_rate_pct,
yoy_growth_pct = EXCLUDED.yoy_growth_pct,
risk_level = EXCLUDED.risk_level,
updated_at = EXCLUDED.updated_at;
CREATE OR REPLACE VIEW hmm_demo.carrier_performance_latest_v AS
SELECT
c.carrier_code,
c.carrier_name,
c.country_code,
c.alliance_name,
c.service_region,
c.performance_tier,
p.performance_month,
p.contracted_teu,
p.shipped_teu,
p.revenue_usd,
p.gross_margin_usd,
p.schedule_reliability_pct,
p.vessel_utilization_pct,
p.claim_rate_pct,
p.yoy_growth_pct,
p.risk_level
FROM hmm_demo.carriers c
JOIN hmm_demo.carrier_monthly_performance p
ON p.carrier_code = c.carrier_code
WHERE p.performance_month = (
SELECT MAX(latest.performance_month)
FROM hmm_demo.carrier_monthly_performance latest
);
COMMENT ON VIEW hmm_demo.carrier_performance_latest_v IS
'Latest fictional monthly KPI for each active demo carrier.';
DO $role$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hmm_federation_reader') THEN
CREATE ROLE hmm_federation_reader NOLOGIN;
END IF;
END
$role$;
GRANT USAGE ON SCHEMA hmm_demo TO hmm_federation_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA hmm_demo TO hmm_federation_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA hmm_demo
GRANT SELECT ON TABLES TO hmm_federation_reader;
COMMIT;
\echo === HMM carrier performance demo: complete ===
\echo Next: run database/source/postgres_hmm_carrier_performance_verify.sql

View File

@@ -1,324 +0,0 @@
-- ============================================================
-- Verification for HMM demo carrier performance data.
-- Redmine: #729
-- ============================================================
\set ON_ERROR_STOP on
\pset pager off
\echo === HMM carrier performance demo: verification begin ===
DO $verify$
DECLARE
carrier_count INTEGER;
performance_count INTEGER;
invalid_month_count INTEGER;
latest_month DATE;
earliest_month DATE;
wrong_series_count INTEGER;
latest_risk_count INTEGER;
wrong_trend_count INTEGER;
reader_can_login BOOLEAN;
BEGIN
SELECT COUNT(*)
INTO carrier_count
FROM hmm_demo.carriers
WHERE carrier_code BETWEEN 'C001' AND 'C008';
SELECT COUNT(*), MIN(performance_month), MAX(performance_month)
INTO performance_count, earliest_month, latest_month
FROM hmm_demo.carrier_monthly_performance
WHERE carrier_code BETWEEN 'C001' AND 'C008'
AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01';
SELECT COUNT(*)
INTO invalid_month_count
FROM hmm_demo.carrier_monthly_performance
WHERE performance_month <> date_trunc('month', performance_month)::DATE;
SELECT COUNT(*)
INTO wrong_series_count
FROM (
SELECT carrier_code, COUNT(*) AS month_count
FROM hmm_demo.carrier_monthly_performance
WHERE carrier_code BETWEEN 'C001' AND 'C008'
AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01'
GROUP BY carrier_code
HAVING COUNT(*) <> 18
) wrong_series;
SELECT COUNT(DISTINCT risk_level)
INTO latest_risk_count
FROM hmm_demo.carrier_performance_latest_v;
WITH boundaries AS (
SELECT
carrier_code,
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2025-02-01')
AS first_revenue,
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2026-07-01')
AS latest_revenue
FROM hmm_demo.carrier_monthly_performance
WHERE carrier_code BETWEEN 'C001' AND 'C008'
GROUP BY carrier_code
)
SELECT COUNT(*)
INTO wrong_trend_count
FROM boundaries
WHERE (carrier_code IN ('C001', 'C002', 'C003', 'C004', 'C007')
AND latest_revenue <= first_revenue)
OR (carrier_code IN ('C005', 'C006', 'C008')
AND latest_revenue >= first_revenue);
SELECT rolcanlogin
INTO reader_can_login
FROM pg_roles
WHERE rolname = 'hmm_federation_reader';
IF carrier_count <> 8 THEN
RAISE EXCEPTION 'Expected 8 carriers, found %', carrier_count;
END IF;
IF performance_count <> 144 THEN
RAISE EXCEPTION 'Expected 144 monthly performance rows, found %', performance_count;
END IF;
IF earliest_month <> DATE '2025-02-01' OR latest_month <> DATE '2026-07-01' THEN
RAISE EXCEPTION 'Unexpected period: % through %', earliest_month, latest_month;
END IF;
IF invalid_month_count <> 0 OR wrong_series_count <> 0 THEN
RAISE EXCEPTION
'Invalid monthly series: invalid dates %, wrong carrier series %',
invalid_month_count,
wrong_series_count;
END IF;
IF latest_risk_count <> 3 THEN
RAISE EXCEPTION 'Expected GREEN, AMBER, RED latest risks, found % levels',
latest_risk_count;
END IF;
IF wrong_trend_count <> 0 THEN
RAISE EXCEPTION 'Unexpected first-to-latest trend for % carriers', wrong_trend_count;
END IF;
IF reader_can_login IS DISTINCT FROM FALSE THEN
RAISE EXCEPTION 'hmm_federation_reader must remain a NOLOGIN role';
END IF;
IF NOT has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'USAGE') THEN
RAISE EXCEPTION 'hmm_federation_reader lacks schema USAGE';
END IF;
IF has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'CREATE') THEN
RAISE EXCEPTION 'hmm_federation_reader unexpectedly has schema CREATE';
END IF;
IF NOT has_table_privilege(
'hmm_federation_reader',
'hmm_demo.carrier_monthly_performance',
'SELECT'
) THEN
RAISE EXCEPTION 'hmm_federation_reader lacks SELECT';
END IF;
IF has_table_privilege(
'hmm_federation_reader',
'hmm_demo.carrier_monthly_performance',
'INSERT'
) THEN
RAISE EXCEPTION 'hmm_federation_reader unexpectedly has INSERT';
END IF;
END
$verify$;
DO $constraint_tests$
BEGIN
BEGIN
INSERT INTO hmm_demo.carrier_monthly_performance (
carrier_code,
performance_month,
contracted_teu,
shipped_teu,
revenue_usd,
gross_margin_usd,
schedule_reliability_pct,
vessel_utilization_pct,
claim_rate_pct,
yoy_growth_pct,
risk_level,
updated_at
) VALUES (
'C001',
DATE '2026-07-02',
1,
1,
1,
1,
90,
90,
1,
1,
'GREEN',
CURRENT_TIMESTAMP
);
RAISE EXCEPTION 'Month-first-day constraint did not reject invalid input';
EXCEPTION
WHEN check_violation THEN
NULL;
END;
BEGIN
INSERT INTO hmm_demo.carrier_monthly_performance (
carrier_code,
performance_month,
contracted_teu,
shipped_teu,
revenue_usd,
gross_margin_usd,
schedule_reliability_pct,
vessel_utilization_pct,
claim_rate_pct,
yoy_growth_pct,
risk_level,
updated_at
) VALUES (
'UNKNOWN',
DATE '2026-08-01',
1,
1,
1,
1,
90,
90,
1,
1,
'GREEN',
CURRENT_TIMESTAMP
);
RAISE EXCEPTION 'Foreign key constraint did not reject unknown carrier';
EXCEPTION
WHEN foreign_key_violation THEN
NULL;
END;
BEGIN
INSERT INTO hmm_demo.carrier_monthly_performance (
carrier_code,
performance_month,
contracted_teu,
shipped_teu,
revenue_usd,
gross_margin_usd,
schedule_reliability_pct,
vessel_utilization_pct,
claim_rate_pct,
yoy_growth_pct,
risk_level,
updated_at
) VALUES (
'C001',
DATE '2026-08-01',
1,
1,
1,
1,
101,
90,
1,
1,
'GREEN',
CURRENT_TIMESTAMP
);
RAISE EXCEPTION 'Percentage constraint did not reject invalid input';
EXCEPTION
WHEN check_violation THEN
NULL;
END;
END
$constraint_tests$;
\echo --- Row counts and period ---
SELECT
COUNT(DISTINCT carrier_code) AS carrier_count,
COUNT(*) AS monthly_row_count,
MIN(performance_month) AS first_month,
MAX(performance_month) AS latest_month
FROM hmm_demo.carrier_monthly_performance
WHERE carrier_code BETWEEN 'C001' AND 'C008'
AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01';
\echo --- Per-carrier series ---
SELECT
c.carrier_code,
c.carrier_name,
COUNT(p.*) AS month_count,
MIN(p.performance_month) AS first_month,
MAX(p.performance_month) AS latest_month
FROM hmm_demo.carriers c
JOIN hmm_demo.carrier_monthly_performance p
ON p.carrier_code = c.carrier_code
WHERE c.carrier_code BETWEEN 'C001' AND 'C008'
GROUP BY c.carrier_code, c.carrier_name
ORDER BY c.carrier_code;
\echo --- Latest KPI and risk distribution ---
SELECT
carrier_code,
carrier_name,
performance_month,
shipped_teu,
revenue_usd,
gross_margin_usd,
schedule_reliability_pct,
claim_rate_pct,
risk_level
FROM hmm_demo.carrier_performance_latest_v
ORDER BY carrier_code;
SELECT risk_level, COUNT(*) AS carrier_count
FROM hmm_demo.carrier_performance_latest_v
GROUP BY risk_level
ORDER BY risk_level;
\echo --- First-to-latest revenue trend ---
WITH boundaries AS (
SELECT
carrier_code,
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2025-02-01') AS first_revenue,
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2026-07-01') AS latest_revenue
FROM hmm_demo.carrier_monthly_performance
WHERE carrier_code BETWEEN 'C001' AND 'C008'
GROUP BY carrier_code
)
SELECT
carrier_code,
first_revenue,
latest_revenue,
CASE
WHEN latest_revenue > first_revenue THEN 'UP'
WHEN latest_revenue < first_revenue THEN 'DOWN'
ELSE 'FLAT'
END AS trend
FROM boundaries
ORDER BY carrier_code;
\echo --- TLS session ---
SELECT
ssl,
version AS tls_version,
cipher,
bits
FROM pg_stat_ssl
WHERE pid = pg_backend_pid();
\echo --- Read-only role privileges ---
SELECT
(SELECT NOT rolcanlogin
FROM pg_roles
WHERE rolname = 'hmm_federation_reader') AS no_login,
has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'USAGE') AS schema_usage,
has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'CREATE') AS schema_create,
has_table_privilege(
'hmm_federation_reader',
'hmm_demo.carrier_monthly_performance',
'SELECT'
) AS can_select,
has_table_privilege(
'hmm_federation_reader',
'hmm_demo.carrier_monthly_performance',
'INSERT'
) AS can_insert;
\echo === HMM carrier performance demo: verification complete ===

View File

@@ -56,10 +56,10 @@ ORDS Handler가 Bearer 값을 문자열로 바꾸는 것만으로 DDS Context가
먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다. 먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다.
```bash ```bash
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/31_dds_standalone_demo_setup.sql sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup.sql
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/32_dds_vector_tag_setup.sql sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql
bash scripts/setup-dds-token-data-grant.sh bash scripts/setup-dds-token-data-grant.sh
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/36_dds_sales_knowledge_scenario.sql sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/36_dds_sales_knowledge_scenario.sql
``` ```
`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다. `32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
@@ -68,7 +68,7 @@ sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/36_dds_sales_knowledge_
`36_dds_sales_knowledge_scenario.sql``agent_sales``SALES_KNOWLEDGE_ROLE`을 만들고 `TECH_TAG=SALES`인 세일즈 청크(28004)를 추가합니다. 짧은 데모 토큰 `dds_sales_demo_token`을 사용하면 결과는 28004만 남아야 합니다. 운영에서는 이 토큰 대신 Bearer 관리 화면에서 발급한 임시 토큰을 사용합니다. `36_dds_sales_knowledge_scenario.sql``agent_sales``SALES_KNOWLEDGE_ROLE`을 만들고 `TECH_TAG=SALES`인 세일즈 청크(28004)를 추가합니다. 짧은 데모 토큰 `dds_sales_demo_token`을 사용하면 결과는 28004만 남아야 합니다. 운영에서는 이 토큰 대신 Bearer 관리 화면에서 발급한 임시 토큰을 사용합니다.
토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `database/adb/37_dds_sales_knowledge_scenario_cleanup.sql``database/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다. 토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql``sql/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다.
그 다음 DDS 인스턴스를 실행합니다. 그 다음 DDS 인스턴스를 실행합니다.

View File

@@ -1,41 +0,0 @@
location = /auth/check {
internal;
proxy_pass http://127.0.0.1:8621/auth/check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
}
location /auth/ {
proxy_pass http://127.0.0.1:8621;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location @hmm_portal_login {
return 302 /auth/login;
}
location / {
auth_request /auth/check;
error_page 401 = @hmm_portal_login;
auth_request_set $hmm_auth_user $upstream_http_x_auth_user;
auth_request_set $hmm_auth_expires $upstream_http_x_auth_expires;
proxy_pass http://127.0.0.1:8622;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-HMM-Authenticated-User $hmm_auth_user;
proxy_set_header X-HMM-Auth-Expires $hmm_auth_expires;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
proxy_send_timeout 300;
}

View File

@@ -1,2 +0,0 @@
[Service]
EnvironmentFile=/etc/hmm-poc4-audit.env

View File

@@ -1,29 +0,0 @@
[Unit]
Description=HMM Portal HttpOnly Cookie Authentication
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=opc
Group=opc
WorkingDirectory=/opt/hmm-poc4
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/opt/hmm-poc4/.env
ExecStart=/opt/hmm-poc4/.venv/bin/python -m ai_web_agent_console.auth_gateway
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6
UMask=0077
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,58 @@
{
servers {
protocols h1 h2
}
}
http://193.122.114.213 {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/smilegate-console-access.log
format console
}
reverse_proxy 127.0.0.1:8622 {
transport http {
versions 1.1
}
}
}
smilegate.cloud-handson.com {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/smilegate-console-access.log
format console
}
reverse_proxy 127.0.0.1:8622 {
transport http {
versions 1.1
}
}
}
smilegate-backoffice.cloud-handson.com {
# Edge clients have left completed zstd-compressed HTML navigations pending.
# Use broadly supported gzip on the management UI; the console keeps zstd.
encode gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/smilegate-backoffice-access.log
format console
}
reverse_proxy 127.0.0.1:8082 {
transport http {
versions 1.1
}
}
}

View File

@@ -1,20 +0,0 @@
# HMM deployment profile for /etc/vpd-backoffice.env.
# Append these non-secret values to the existing file. Keep DB passwords,
# remember-me keys and user Bearer tokens outside Git.
BACKOFFICE_CATALOG_OWNER=ADMIN
BACKOFFICE_CATALOG_OBJECTS='[{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE","businessName":"조직 원장","description":"HMM HR 조직·팀 기본정보"},{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE","businessName":"직원 원장","description":"직원·매니저·소속팀 정보"},{"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"TABLE","businessName":"휴가 잔여 원장","description":"직원별 연도·휴가 유형별 부여·사용·잔여 일수"},{"key":"leave-requests","tableName":"HMM_LEAVE_REQUESTS","objectType":"TABLE","businessName":"휴가 신청 원장","description":"직원별 휴가 신청·승인 상태와 기간"},{"key":"attendance-daily","tableName":"HMM_ATTENDANCE_DAILY","objectType":"TABLE","businessName":"일별 근태 원장","description":"직원별 출퇴근·근무 상태와 근무 시간"},{"key":"hr-terms","tableName":"HMM_HR_TERMS","objectType":"TABLE","businessName":"HR 표준 용어 원장","description":"휴가·근태 표준 코드, 명칭과 유사 표현","previewColumns":["TERM_ID","TERM_CODE","TERM_KIND","CANONICAL_NAME","TERM_NAME","IS_CANONICAL","DESCRIPTION","EMBEDDED_AT"]}]'
BACKOFFICE_PRODUCT_NAME='HMM HR Access Console'
BACKOFFICE_PRODUCT_TITLE='HMM HR Access Console'
BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
# AGENT_TOOL targetName must exist with STATUS=ENABLED in USER_AI_AGENT_TOOLS.
# The application fails startup before advertising an invalid configured contract.
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"SELECT_AI"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'
BACKOFFICE_SECURITY_SQL_SCRIPTS='[{"scriptId":"hmm-leave-vpd","category":"HMM / VPD","fileName":"72_hmm_leave_team_vpd.sql","title":"HMM 휴가 팀 접근 정책","description":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]'

View File

@@ -1,2 +0,0 @@
[Service]
EnvironmentFile=/etc/vpd-backoffice.env

View File

@@ -8,7 +8,7 @@
> 단위 매트릭스** 입니다 (README 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행 > 단위 매트릭스** 입니다 (README 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행
> 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로 > 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로
> 사용합니다 — VPD 메커니즘 자체는 동일하므로 개념 이해에는 차이가 없습니다. > 사용합니다 — VPD 메커니즘 자체는 동일하므로 개념 이해에는 차이가 없습니다.
> region 필터를 실제로 켜려면 `database/adb/03_seed.sql` 하단의 주석을 해제하세요. > region 필터를 실제로 켜려면 `sql/adb/03_seed.sql` 하단의 주석을 해제하세요.
--- ---
@@ -543,7 +543,7 @@ SQL> SELECT COUNT(*) FROM admin.v_customers_pg; -- 0
SQL> SELECT COUNT(*) FROM admin.v_customers_my; -- 0 SQL> SELECT COUNT(*) FROM admin.v_customers_my; -- 0
# region 필터 변형을 켰을 때 — vpduser_both 에게 APAC 만 허용한 경우 # region 필터 변형을 켰을 때 — vpduser_both 에게 APAC 만 허용한 경우
# (database/adb/03_seed.sql 하단 UPDATE 주석 해제 후) # (sql/adb/03_seed.sql 하단 UPDATE 주석 해제 후)
sqlplus "vpduser_both/\"${VPDUSER_BOTH_PASSWORD}\"@$ADB_TNS" sqlplus "vpduser_both/\"${VPDUSER_BOTH_PASSWORD}\"@$ADB_TNS"
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region; SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
-- 결과: APAC 만 보임 -- 결과: APAC 만 보임

View File

@@ -4,7 +4,7 @@
--- ---
## Postgres (`database/source/postgres_setup.sql`) ## Postgres (`sql/source/postgres_setup.sql`)
``` ```
DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc) DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc)
@@ -22,12 +22,12 @@ seed rows : 12 (APAC 4 / EMEA 4 / AMER 4)
```bash ```bash
PGPASSWORD=$PG_PASSWORD psql \ PGPASSWORD=$PG_PASSWORD psql \
-h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \
-f database/source/postgres_setup.sql -f sql/source/postgres_setup.sql
``` ```
--- ---
## MySQL (`database/source/mysql_setup.sql`) ## MySQL (`sql/source/mysql_setup.sql`)
``` ```
DB : MY_DB (기본 ecommerce_poc) DB : MY_DB (기본 ecommerce_poc)
@@ -47,12 +47,12 @@ PK 범위를 PG (1~12) 와 다르게 가져간 이유:
```bash ```bash
mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" $MY_DB \ mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" $MY_DB \
< database/source/mysql_setup.sql < sql/source/mysql_setup.sql
``` ```
--- ---
## ADB → 원격 DB Link (`database/adb/01_dblinks.sql`) ## ADB → 원격 DB Link (`sql/adb/01_dblinks.sql`)
`DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을 `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을
처리합니다. 별도 Database Gateway 설치 불필요. 처리합니다. 별도 Database Gateway 설치 불필요.

View File

@@ -2,7 +2,7 @@
> 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로 > 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로
> 그대로 동작합니다. 이 문서는 동일한 4-user 매트릭스를 Oracle AI Database > 그대로 동작합니다. 이 문서는 동일한 4-user 매트릭스를 Oracle AI Database
> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `database/adb/13_dds_variant.sql` > **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `sql/adb/13_dds_variant.sql`
> 의 사용법과, VPD ↔ DDS 1:1 매핑을 다룹니다. > 의 사용법과, VPD ↔ DDS 1:1 매핑을 다룹니다.
> >
> 본 변형은 **2026-05-26 실제 ADB (23.26.2.2.0) 에서 E2E 검증** 됨 (8장 결과). > 본 변형은 **2026-05-26 실제 ADB (23.26.2.2.0) 에서 E2E 검증** 됨 (8장 결과).
@@ -84,7 +84,7 @@ Oracle 이 **2026-04-09** 에 VPD/RAS 의 공식 후계자로 발표한 native a
```bash ```bash
source .env # DDSUSER_*_PASSWORD 로드 source .env # DDSUSER_*_PASSWORD 로드
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/13_dds_variant.sql sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/13_dds_variant.sql
``` ```
스크립트가 만드는 객체: 스크립트가 만드는 객체:
@@ -218,7 +218,7 @@ DROP VIEW v_dds_customers_my;
## 8. E2E 검증 결과 (2026-05-26, ADB 23.26.2.2.0) ## 8. E2E 검증 결과 (2026-05-26, ADB 23.26.2.2.0)
`database/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회: `sql/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회:
| 사용자 | `v_dds_customers_pg` | `v_dds_customers_my` | 판정 | | 사용자 | `v_dds_customers_pg` | `v_dds_customers_my` | 판정 |
|---|---|---|---| |---|---|---|---|

View File

@@ -1165,15 +1165,15 @@ ORDER BY rg.grantee, dg.object_name, dg.grant_name;
| 파일 | 역할 | | 파일 | 역할 |
|---|---| |---|---|
| `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 | | `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 |
| `database/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 | | `sql/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 |
| `database/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 | | `sql/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 |
| `database/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 | | `sql/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 |
| `database/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 | | `sql/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 |
| `database/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 | | `sql/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 |
| `database/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable | | `sql/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable |
| `database/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 | | `sql/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 |
| `database/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 | | `sql/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 |
| `database/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 | | `sql/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 |
### 7.1 VPD + Redaction 실행 검증 ### 7.1 VPD + Redaction 실행 검증
@@ -1634,7 +1634,7 @@ ORA-20101: Authorization header must be Bearer <key>
### 7.4 중앙 권한 인벤토리 ### 7.4 중앙 권한 인벤토리
VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `database/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다. VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `sql/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다.
VPD 정책 연결 확인: VPD 정책 연결 확인:

View File

@@ -17,7 +17,6 @@ docs/
adr/ ← Architecture Decision Records: 가로지르는 결정 기록 adr/ ← Architecture Decision Records: 가로지르는 결정 기록
_TEMPLATE.md _TEMPLATE.md
NNNN-<title>.md NNNN-<title>.md
architecture/ ← 현행 시스템 아키텍처 Draw.io 원본과 검토용 SVG/PNG
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화) reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상) guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북) pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
@@ -62,11 +61,3 @@ docs/
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지). `Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다. 구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
``` ```
# 문서 안내
## 공통 운영 문서
- [MCP·VPD·Data Redaction·DDS 공통 운영 가이드](runbooks/mcp-vpd-redaction-dss-operations.md): 고객사와 무관한 구성, 적용, 검증, 롤백 기준
- [SQLcl VPD 배포·검증·롤백 런북](runbooks/460-sqlcl-vpd-deploy-runbook.md): DB 적용과 장애 점검 절차
고객사별 데이터 모델, MCP 도구, 질의 예제는 `main`이 아니라 해당 고객사 브랜치에서 관리한다.

View File

@@ -1,76 +0,0 @@
# HMM AI 데이터 접근 아키텍처
OCI Draw.io Style Guide의 공식 서비스 도형과 Oracle 색상 규칙으로 정리한 HMM 데모
아키텍처다. Draw.io 원본은 두 페이지로 구성한다.
| 페이지 | 설명 | 미리보기 |
|---|---|---|
| `01 · 전체 구성` | HMM 사용자, Compute, Backoffice MCP, ADB, GenAI, Object Storage, AWS RDS federation | [SVG](hmm-ai-data-access-architecture-overview.svg) · [PNG](hmm-ai-data-access-architecture-overview.png) |
| `02 · MCP 요청과 VPD` | Tool 광고 시작 검증, 사용자 Bearer 인증, Agent Tool과 Select AI 분기, `CB_ORDS` VPD 실행 | [SVG](hmm-ai-data-access-architecture-security-flow.svg) · [PNG](hmm-ai-data-access-architecture-security-flow.png) |
편집 원본: [hmm-ai-data-access-architecture.drawio](hmm-ai-data-access-architecture.drawio)
## 현행 기준
- 사용자별 MCP: `https://hmm-backoffice.cloud-handson.com/mcp`
- 공개 Tool 계약: `BACKOFFICE_MCP_TOOLS`
- ADB Agent Tool: `resolve_hr_term`, `search_hr_policy`
- Java Select AI Tool: `search_hr_data`
- Select AI SQL 생성: ADMIN 세션
- 보호 SQL 실행: `CB_ORDS` 비면제 읽기 전용 세션
- VPD 문맥: `HMM_ACCESS_CTX`
- 휴가 정책: `HMM_LEAVE_SCOPE_POLICY`
- 정책 문서: Object Storage PDF → Abstract/Tag/Chunk/Embed 4 Vector
- 선사 실적: AWS RDS PostgreSQL → `HMM_RDS_PG_LINK` → ADB federation View
- 공용 호환 게이트웨이 `hmm-mcp.cloud-handson.com`은 사용자별 VPD MCP 주소가 아니다.
## 흐름을 읽는 방법
- 파란 실선: 사용자 요청 또는 읽기 전용 데이터 실행
- Oracle red 실선: 인증, 보안 Context, VPD 적용
- 주황 점선: 모델 호출 또는 문서 적재
- 회색 실선: 외부 PostgreSQL federation
- 녹색 실선: 정상 결과와 세션 정리
`AGENT_TOOL``SELECT_AI`의 DB 세션은 구분한다.
- `AGENT_TOOL`: 기본 datasource의 같은 connection에서
`SET_USER_BY_BEARER → DBMS_CLOUD_AI_AGENT.RUN_TOOL → CLEAR_USER`
- `search_hr_data`: ADMIN이 SQL만 생성하고, `CB_ORDS`의 같은 connection에서
`SET_VPD_CONTEXT → SELECT → ROLLBACK → CLEAR_VPD_CONTEXT`
## 재생성
로컬 OCI 라이브러리 경로를 명시한다. 비밀번호, Token, OCID는 입력하지 않는다.
```bash
node tools/architecture/generate-hmm-oci-architecture.mjs \
"/Users/joungminko/Downloads/OCI Style Guide for Drawio/OCI Library.xml" \
docs/architecture
```
SVG와 PNG는 diagrams.net의 embed exporter를 Playwright로 호출해 실제 Draw.io 렌더링과
동일하게 만든다. 인터넷 연결과 Playwright Chromium이 필요하다.
```bash
node tools/architecture/export-drawio.mjs \
docs/architecture/hmm-ai-data-access-architecture.drawio \
docs/architecture
```
구조, 필수 문구, OCI stencil, 비밀값 패턴, 렌더링 크기를 검사한다.
```bash
node tools/architecture/validate-hmm-architecture.mjs docs/architecture
```
## 스타일 기준
- 기준 파일: `OCI Library.xml`, `Read-ME.drawio`
- 글꼴: Oracle Sans
- 본문: `#312D2A`
- OCI 경계: `#F5F4F2` / `#9E9892`
- OCI 강조: `#AE562C`
- 보안 강조: `#C74634`
- 외부 Cloud: 흰 배경과 회색 점선
- Style Guide의 안내용 pink와 Courier New는 사용하지 않는다.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
> **상태**: Draft > **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-23 > **작성**: [AI] Architect · **최종수정**: 2026-06-23
> **추적성** — Redmine: #424 · 관련 ADR: 없음 > **추적성** — Redmine: #424 · 관련 ADR: 없음
> · 구현 파일: `vpd-backoffice/pom.xml`, `vpd-backoffice/src/main/**`, `vpd-backoffice/src/test/**` · 테스트: `mvn test` > · 구현 파일: `pom.xml`, `src/main/**`, `src/test/**` · 테스트: `mvn test`
## 1. 목적 (Why) ## 1. 목적 (Why)
@@ -58,8 +58,8 @@
### 모듈/파일 구조 ### 모듈/파일 구조
```text ```text
vpd-backoffice/pom.xml pom.xml
vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/ src/main/java/com/cloudhandson/vpdbackoffice/
VpdBackofficeApplication.java VpdBackofficeApplication.java
config/ config/
DataSourceConfig.java DataSourceConfig.java
@@ -88,13 +88,13 @@ vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/
TokenController.java TokenController.java
ProbeController.java ProbeController.java
DashboardController.java DashboardController.java
vpd-backoffice/src/main/resources/ src/main/resources/
application.yml application.yml
mapper/*.xml mapper/*.xml
templates/**/*.html templates/**/*.html
static/css/app.css static/css/app.css
static/js/app.js static/js/app.js
vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/ src/test/java/com/cloudhandson/vpdbackoffice/
``` ```
### 데이터 흐름 ### 데이터 흐름
@@ -251,7 +251,7 @@ MCP-style reasoning
## 12. 미해결 질문 (Open Questions) ## 12. 미해결 질문 (Open Questions)
- ORDS 검증 API는 기존 `database/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다. - ORDS 검증 API는 기존 `sql/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다.
- 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다. - 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다.
- 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다. - 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다.
- 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다. - 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다.

View File

@@ -3,7 +3,7 @@
> **상태**: Approved > **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25 > **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #456 · 관련 ADR: 없음 > **추적성** — Redmine: #456 · 관련 ADR: 없음
> · 구현 파일: `database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test` > · 구현 파일: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test`
## 1. 목적 (Why) ## 1. 목적 (Why)
@@ -78,8 +78,8 @@ I/O와 순수 로직 경계:
## 10. 테스트 계획 ## 10. 테스트 계획
- SQLcl: `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql` - SQLcl: `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
- SQLcl: `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` - SQLcl: `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
- Maven: `mvn test` - Maven: `mvn test`
- ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인. - ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인.

View File

@@ -1,7 +1,7 @@
# 함수 설계서: `cb_agent_doc_vpd_filter` (#456) # 함수 설계서: `cb_agent_doc_vpd_filter` (#456)
> **부모 설계서**: ./README.md · **상태**: Approved > **부모 설계서**: ./README.md · **상태**: Approved
> **작성**: [AI] Architect · **구현**: `database/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` > **작성**: [AI] Architect · **구현**: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
## 1. 시그니처 ## 1. 시그니처

View File

@@ -3,7 +3,7 @@
> **상태**: Approved > **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25 > **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #462 · 관련 ADR: 없음 > **추적성** — Redmine: #462 · 관련 ADR: 없음
> · 구현 파일: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test` > · 구현 파일: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test`
## 1. 목적 (Why) ## 1. 목적 (Why)
@@ -67,8 +67,8 @@ malicious cb_permission_rule rows
## 10. 테스트 계획 ## 10. 테스트 계획
- `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql` - `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
- `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` - `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
- `./run.sh backoffice-vpd-ords-test` - `./run.sh backoffice-vpd-ords-test`
- `mvn test` - `mvn test`

View File

@@ -3,7 +3,7 @@
> **상태**: Approved > **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-28 > **작성**: [AI] Architect · **최종수정**: 2026-06-28
> **추적성** — Redmine: #547 · 관련 ADR: 없음 > **추적성** — Redmine: #547 · 관련 ADR: 없음
> · 구현 파일: `vpd-backoffice/src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/vpd-backoffice/caddy/Caddyfile.template` > · 구현 파일: `src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/caddy/Caddyfile.template`
> · 테스트: `TransportSecurityTest.java`, `scripts/test-backoffice-https-config.sh` > · 테스트: `TransportSecurityTest.java`, `scripts/test-backoffice-https-config.sh`
## 1. 목적 (Why) ## 1. 목적 (Why)
@@ -49,7 +49,7 @@ Internet client
Internet client ── HTTP :8082 ──X (loopback bind + firewalld/NSG deny) Internet client ── HTTP :8082 ──X (loopback bind + firewalld/NSG deny)
``` ```
- `deploy/vpd-backoffice/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정. - `deploy/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정.
- `scripts/configure-backoffice-https-vm.sh`: 입력 검증, 원격 설정 검증·백업·적용·확인. - `scripts/configure-backoffice-https-vm.sh`: 입력 검증, 원격 설정 검증·백업·적용·확인.
- `scripts/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크. - `scripts/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크.
- Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다. - Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다.

View File

@@ -2,7 +2,7 @@
> **상태**: VPD·DDS 병행 검증 완료, 토큰 기반 객체별 Data Grant 적용 완료 > **상태**: VPD·DDS 병행 검증 완료, 토큰 기반 객체별 Data Grant 적용 완료
> **추적성**: Redmine #565, #566 · DDS 구현 커밋 `1e48864`, `a5e70bc`, `04cca64`, `8978be4` > **추적성**: Redmine #565, #566 · DDS 구현 커밋 `1e48864`, `a5e70bc`, `04cca64`, `8978be4`
> **기준 구현**: `database/adb/32_dds_vector_tag_setup.sql`, `database/adb/34_dds_token_data_grant_common_auth.sql`, `database/adb/36_dds_sales_knowledge_scenario.sql`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java` > **기준 구현**: `sql/adb/32_dds_vector_tag_setup.sql`, `sql/adb/34_dds_token_data_grant_common_auth.sql`, `sql/adb/36_dds_sales_knowledge_scenario.sql`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java`
## 목적 ## 목적

View File

@@ -77,8 +77,8 @@ Authorization: Bearer <masked>
## 설치와 운영 절차 ## 설치와 운영 절차
1. `ADMIN`으로 [42_agent_ords_fga_execution_audit.sql](../../../database/adb/42_agent_ords_fga_execution_audit.sql)을 실행해 활성 보호 객체마다 FGA `SELECT` 정책을 만든다. 1. `ADMIN`으로 [42_agent_ords_fga_execution_audit.sql](../../../sql/adb/42_agent_ords_fga_execution_audit.sql)을 실행해 활성 보호 객체마다 FGA `SELECT` 정책을 만든다.
2. `CB_ORDS`로 [22_agent_ords_security_ords_handler_setup.sql](../../../database/adb/22_agent_ords_security_ords_handler_setup.sql), [29_agent_ords_vector_search_ords.sql](../../../database/adb/29_agent_ords_vector_search_ords.sql), [43_agent_ords_probe_id_handler_patch.sql](../../../database/adb/43_agent_ords_probe_id_handler_patch.sql)을 순서대로 실행한다. 2. `CB_ORDS`로 [22_agent_ords_security_ords_handler_setup.sql](../../../sql/adb/22_agent_ords_security_ords_handler_setup.sql), [29_agent_ords_vector_search_ords.sql](../../../sql/adb/29_agent_ords_vector_search_ords.sql), [43_agent_ords_probe_id_handler_patch.sql](../../../sql/adb/43_agent_ords_probe_id_handler_patch.sql)을 순서대로 실행한다.
3. 새 권한 결과 확인을 실행한다. 이전 요청에는 `CLIENT_ID`가 없으므로 소급해 매칭하지 않는다. 3. 새 권한 결과 확인을 실행한다. 이전 요청에는 `CLIENT_ID`가 없으므로 소급해 매칭하지 않는다.
4. 백오피스 실행 계정이 Autonomous의 `UNIFIED_AUDIT_TRAIL`을 조회할 수 있어야 한다. 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 사용한다. 일반 계정이면 감사 조회 권한을 가진 전용 observer 계정을 사용한다. 4. 백오피스 실행 계정이 Autonomous의 `UNIFIED_AUDIT_TRAIL`을 조회할 수 있어야 한다. 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 사용한다. 일반 계정이면 감사 조회 권한을 가진 전용 observer 계정을 사용한다.

View File

@@ -1,7 +1,7 @@
# 함수 설계서: `addLocalSqlTrace` (#567) # 함수 설계서: `addLocalSqlTrace` (#567)
> **부모 설계서**: ./README.md · **상태**: Approved > **부모 설계서**: ./README.md · **상태**: Approved
> **작성**: [AI] Architect · **구현**: `vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test` > **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test`
## 1. 시그니처 ## 1. 시그니처

View File

@@ -1,7 +1,7 @@
# 함수 설계서: `findVpdPredicate` (#567) # 함수 설계서: `findVpdPredicate` (#567)
> **부모 설계서**: ./README.md · **상태**: Approved > **부모 설계서**: ./README.md · **상태**: Approved
> **작성**: [AI] Architect · **구현**: `vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke > **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke
## 1. 시그니처 ## 1. 시그니처

View File

@@ -4,8 +4,8 @@
> **최종수정**: 2026-07-19 > **최종수정**: 2026-07-19
> **추적성** — Redmine: #617 · 관련 ADR: [ADR-0001](../../adr/0001-dds-mcp-service-identity.md), [ADR-0002](../../adr/0002-dds-runtime-permission-source.md) > **추적성** — Redmine: #617 · 관련 ADR: [ADR-0001](../../adr/0001-dds-mcp-service-identity.md), [ADR-0002](../../adr/0002-dds-runtime-permission-source.md)
> · 현재 구현: `DdsMcpBearerAuthenticator`, `DdsMcpEndUserResolver`, `DdsMcpContextExecutor`, `DdsMcpSseController` > · 현재 구현: `DdsMcpBearerAuthenticator`, `DdsMcpEndUserResolver`, `DdsMcpContextExecutor`, `DdsMcpSseController`
> · 기존 PoC 게시: `database/adb/44_dds_mcp_local_end_user_setup.sql` > · 기존 PoC 게시: `sql/adb/44_dds_mcp_local_end_user_setup.sql`
> · 검증: `database/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call` > · 검증: `sql/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call`
## 1. 결정과 목적 ## 1. 결정과 목적

View File

@@ -3,9 +3,7 @@
> **상태**: Implemented snapshot documented · follow-up contract pending > **상태**: Implemented snapshot documented · follow-up contract pending
> **작성**: [AI] Architect · **최종수정**: 2026-07-14 > **작성**: [AI] Architect · **최종수정**: 2026-07-14
> **추적성** — Redmine: #654 · 관련 ADR: 없음 · 구현 커밋: `5bdd242`, `33399f9`, `1f6d974` > **추적성** — Redmine: #654 · 관련 ADR: 없음 · 구현 커밋: `5bdd242`, `33399f9`, `1f6d974`
> · 현재 소스: `ai-web-agent-console/` · 현행 구현 파일: `ai-web-agent-console/app.py`, `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py`, `ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py`, `ai-web-agent-console/ai_web_agent_console/model_registry.py`, `ai-web-agent-console/config/mcp_servers.json` · 테스트: Python 3.11 `compileall`/`unittest`, `mvn -f vpd-backoffice/pom.xml test` > · 스냅샷: `poc4_active_source_20260714/` · 원본 archive: `poc4_active_source_20260714/poc4_active_source_20260714.tar.gz` · 현행 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `src/mcp_tool_router.py`, `src/oci_genai_sdk.py`, `src/poc3/model_registry.py`, `config/mcp_servers.json` · 테스트: Python 3.11 `py_compile`, 기존 백오피스 `mvn -q test`
>
> 이 문서의 PoC4 명칭과 원격 archive 경로는 2026-07-14 당시의 이력이다. 현재 저장소 구조는 #742 설계서를 기준으로 한다.
## 1. 목적 (Why) ## 1. 목적 (Why)
@@ -19,17 +17,17 @@ PoC4 MCP AI Console이 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하
| 영역 | 현행 구현 | | 영역 | 현행 구현 |
|---|---| |---|---|
| 저장소 위치 | `ai-web-agent-console/` 단일 폴더에 격리 | | 저장소 위치 | `poc4_active_source_20260714/` 단일 폴더에 격리 |
| 실행 진입점 | 직접 실행은 `streamlit run ai-web-agent-console/app.py --server.address 0.0.0.0 --server.port 8622` | | 실행 진입점 | 직접 실행은 `streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622` |
| Python 런타임 | Python 3.11 이상. 배포 서버 검증 런타임은 `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` | | Python 런타임 | Python 3.11 이상. 배포 서버 검증 런타임은 `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` |
| Streamlit 화면 | `ai-web-agent-console/app.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 | | Streamlit 화면 | `apps/poc4/mcp_discovery_ui.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 |
| MCP registry | `ai-web-agent-console/config/mcp_servers.json` | | MCP registry | `config/mcp_servers.json`. `kb_mcp``kb_vector_mcp` 두 서버를 선언 |
| KB 정형 MCP | `https://kb.cloud-handson.com/mcp`, 기본 tool `ords.query.kb_select_ai_vpd` | | KB 정형 MCP | `https://kb.cloud-handson.com/mcp`, 기본 tool `ords.query.kb_select_ai_vpd` |
| KB 벡터 MCP | `http://127.0.0.1:9978/mcp`, allowlist `hybrid_rerank_search` | | KB 벡터 MCP | `http://127.0.0.1:9978/mcp`, allowlist `hybrid_rerank_search` |
| 토큰 preset | `ai-web-agent-console/config/vpd_token_presets.json`. 실제 토큰 원문은 포함하지 않고 placeholder만 둠 | | 토큰 preset | `config/vpd_token_presets.json`. 실제 토큰 원문은 포함하지 않고 placeholder만 둠 |
| 대화 저장소 | 기본 `data/poc4_mcp_chat.sqlite3`, 환경변수 `POC4_CHAT_DB_PATH`로 변경 가능 | | 대화 저장소 | 기본 `data/poc4_mcp_chat.sqlite3`, 환경변수 `POC4_CHAT_DB_PATH`로 변경 가능 |
| DB evidence 연결 | 기본 env file `/home/opc/kbmcp/.env`, wallet fallback `/home/opc/wallet/kbaipoc` | | DB evidence 연결 | 기본 env file `/home/opc/kbmcp/.env`, wallet fallback `/home/opc/wallet/kbaipoc` |
| OCI GenAI | `OCI_AUTH_TYPE=config_file`, `~/.oci/config`, `DEFAULT` profile 기반. model profile은 `ai-web-agent-console/config/model_profiles.json`에서 로드 | | OCI GenAI | `OCI_AUTH_TYPE=config_file`, `~/.oci/config`, `DEFAULT` profile 기반. model profile은 `config/poc3_model_profiles.json`에서 로드 |
| 보안 원칙 | wallet, DB password, wallet password, 실제 VPD token, 대화 DB는 저장소에 포함하지 않음 | | 보안 원칙 | wallet, DB password, wallet password, 실제 VPD token, 대화 DB는 저장소에 포함하지 않음 |
현행 데이터 흐름은 다음과 같다. 현행 데이터 흐름은 다음과 같다.
@@ -62,24 +60,25 @@ SQLite 대화 이력 저장
| 파일 | 책임 | | 파일 | 책임 |
|---|---| |---|---|
| `ai-web-agent-console/app.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 | | `apps/poc4/mcp_discovery_ui.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 |
| `ai-web-agent-console/ai_web_agent_console/presentation.py` | 공통 UI theme와 화면 표현 보조 코드 | | `apps/poc4/ui_theme.py` | UI theme 보조 코드 |
| `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py` | 발견된 MCP tool descriptor를 기반으로 LLM router가 server/tool을 선택하고 tool arguments를 구성 | | `src/mcp_tool_router.py` | 발견된 MCP tool descriptor를 기반으로 LLM router가 server/tool을 선택하고 tool arguments를 구성 |
| `ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py` | OCI Generative AI 호출 경계. MCP/VPD token을 알지 않는 최소 completion client | | `src/oci_genai_sdk.py` | OCI Generative AI 호출 경계. MCP/VPD token을 알지 않는 최소 completion client |
| `ai-web-agent-console/ai_web_agent_console/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 | | `src/poc3/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 |
| `ai-web-agent-console/ai_web_agent_console/questions.py` | 데모 질문 목록 | | `src/poc3/questions.py` | 데모 질문 목록 |
| `ai-web-agent-console/config/mcp_servers.json` | MCP 서버 registry | | `config/mcp_servers.json` | MCP 서버 registry. 현재는 `endpoint_url`이 있으면 이를 우선 사용 |
| `ai-web-agent-console/config/model_profiles.json` | OCI GenAI model profile registry | | `config/poc3_model_profiles.json` | `gpt55_oci`, `gpt54_mini_oci`, `grok43`, `llama4_maverick`, `llama33_70b` profile |
| `ai-web-agent-console/config/vpd_token_presets.json` | 데모 사용자 token preset 구조. 실제 토큰은 배포 환경에서 교체 | | `config/vpd_token_presets.json` | 데모 사용자 token preset 구조. 실제 토큰은 배포 환경에서 교체 |
| `scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh` | 원본 PoC4 런처 wrapper. 현재 스냅샷의 직접 entrypoint와는 다르게 `/home/opc/poc_4/scripts/poc4/run_8622_langgraph_tc_ui.sh``apps/poc4/langgraph_tc_ui.py`를 참조 |
현재 저장소에서는 `cd ai-web-agent-console && streamlit run app.py`를 독립 실행 진입점으로 사용한다. 런처 주의사항: 스냅샷에는 `mcp_discovery_ui.py` 직접 실행에 필요한 소스가 들어 있지만, 포함된 `start_8622...` script는 원본 서버의 공용 runtime wrapper에 의존한다. 이 저장소에서 독립 실행하려면 `SOURCE_README.md`의 직접 `streamlit run` 명령을 사용하거나, 별도 wrapper를 작성해야 한다.
## 1.3 현행 보안/비밀정보 경계 ## 1.3 현행 보안/비밀정보 경계
- `VpdTokenPreset.token`은 dataclass에서 `repr=False`이며, UI는 token을 정규화한 뒤 Authorization header에만 넣는다. - `VpdTokenPreset.token`은 dataclass에서 `repr=False`이며, UI는 token을 정규화한 뒤 Authorization header에만 넣는다.
- `_NoRedirectHandler`는 redirect 시 Authorization header가 다른 endpoint로 전달되는 것을 막는다. - `_NoRedirectHandler`는 redirect 시 Authorization header가 다른 endpoint로 전달되는 것을 막는다.
- `ai_web_agent_console.mcp_tool_router`의 router는 질문과 tool descriptor만 받으며 bearer token 또는 provider credential을 받지 않는다. - `src/mcp_tool_router.py`의 router는 질문과 tool descriptor만 받으며 bearer token 또는 provider credential을 받지 않는다.
- `ai_web_agent_console.oci_genai_sdk``.env`에서 `OCI_AUTH_TYPE`, `OCI_CONFIG_FILE`, `OCI_GENAI_COMPARTMENT_ID`, `OCI_PROFILE`만 읽는다. - `src/oci_genai_sdk.py``.env`에서 `OCI_AUTH_TYPE`, `OCI_CONFIG_FILE`, `OCI_GENAI_COMPARTMENT_ID`, `OCI_PROFILE`만 읽는다.
- Oracle audit/business evidence 조회는 `/home/opc/kbmcp/.env`에서 `ORACLE_DB_USER`, `ORACLE_DB_PASSWORD`, `ORACLE_DSN`, `ORACLE_WALLET_PASSWORD`, `ORACLE_WALLET_DIR`를 읽는다. - Oracle audit/business evidence 조회는 `/home/opc/kbmcp/.env`에서 `ORACLE_DB_USER`, `ORACLE_DB_PASSWORD`, `ORACLE_DSN`, `ORACLE_WALLET_PASSWORD`, `ORACLE_WALLET_DIR`를 읽는다.
- wallet 기본 fallback은 `/home/opc/wallet/kbaipoc`이다. - wallet 기본 fallback은 `/home/opc/wallet/kbaipoc`이다.
- 저장소에는 실제 `.env`, wallet, SQLite 대화 DB, 실제 VPD bearer token 원문을 포함하지 않는다. - 저장소에는 실제 `.env`, wallet, SQLite 대화 DB, 실제 VPD bearer token 원문을 포함하지 않는다.
@@ -98,7 +97,7 @@ SQLite 대화 이력 저장
## 2. 범위 (Scope) ## 2. 범위 (Scope)
- **포함**: - **포함**:
- `ai-web-agent-console/app.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비 - `apps/poc4/mcp_discovery_ui.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비
- `config/mcp_servers.json` 및 sample의 KB MCP 선언 정비 - `config/mcp_servers.json` 및 sample의 KB MCP 선언 정비
- KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화 - KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화
- VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완 - VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완
@@ -301,8 +300,8 @@ Authorization: Bearer <current-user-vpd-token>
배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다. 배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다.
- 원격 실제 위치: `/home/opc/poc_4/poc4_active_source_20260714.tar.gz` - 원격 실제 위치: `/home/opc/poc_4/poc4_active_source_20260714.tar.gz`
- 사용자 제시 경로 `/home/opc/poc_4/ai-web-agent-console/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다. - 사용자 제시 경로 `/home/opc/poc_4/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다.
- 저장소 위치: `ai-web-agent-console/` - 저장소 위치: `poc4_active_source_20260714/`
- 포함 파일: Streamlit UI, MCP router, OCI GenAI client, 모델 profile, token preset sample, 기동/status script, requirements - 포함 파일: Streamlit UI, MCP router, OCI GenAI client, 모델 profile, token preset sample, 기동/status script, requirements
- 보안 확인: 실제 `.env`, 실제 VPD token 원문, 대화 SQLite DB는 포함하지 않았다. `vpd_token_presets.json`에는 placeholder만 있다. - 보안 확인: 실제 `.env`, 실제 VPD token 원문, 대화 SQLite DB는 포함하지 않았다. `vpd_token_presets.json`에는 placeholder만 있다.
- DB 참조 경로: VPD 개발본 배포 서버에서는 `/home/opc/kbmcp/.env`의 접속 정보를 사용하고, wallet directory는 `/home/opc/wallet/kbaipoc`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다. - DB 참조 경로: VPD 개발본 배포 서버에서는 `/home/opc/kbmcp/.env`의 접속 정보를 사용하고, wallet directory는 `/home/opc/wallet/kbaipoc`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다.
@@ -381,7 +380,7 @@ ENABLED_COUNT = 4
SELECT_POLICY_COUNT = 4 SELECT_POLICY_COUNT = 4
``` ```
현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `database/adb/42_agent_ords_fga_execution_audit.sql``CB_VPD_EXEC_AUDIT_<object_id>` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다. 현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `sql/adb/42_agent_ords_fga_execution_audit.sql``CB_VPD_EXEC_AUDIT_<object_id>` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다.
### 15.4 감사 이벤트 저장 위치 ### 15.4 감사 이벤트 저장 위치
@@ -438,7 +437,7 @@ DBA_FGA_AUDIT_TRAIL event_count_7d = 0
### 15.6 소스 구현과 화면 연결 ### 15.6 소스 구현과 화면 연결
PoC4 스냅샷의 `ai-web-agent-console/app.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다. PoC4 스냅샷의 `apps/poc4/mcp_discovery_ui.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다.
| 함수 | 조회 대상 | 역할 | | 함수 | 조회 대상 | 역할 |
|---|---|---| |---|---|---|

View File

@@ -1,104 +0,0 @@
# HMM MCP 데모 사용자 preset (#699)
> 상태: Verified / Deployed (2026-07-23)
> 추적: Redmine #699 / Git 브랜치: `hmm-backoffice`
> 대상: `hmm.cloud-handson.com` / `/opt/hmm-poc4`
## 목적
HMM AI 업무 에이전트에서 HMM 가상 HR 조직의 팀장·팀원을 선택해 같은 질문을 사용자 관점으로
시험한다. MCP 접속 토큰과 데모 사용자 정보를 분리하여 토큰 원문을 화면·JSON·Git에 남기지 않는다.
## 구성
```text
vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_env)
→ Streamlit 데모 사용자 선택
→ HMM MCP gateway bearer token (HMM_MCP_BEARER_TOKEN, 서버 .env 전용)
→ hmm-mcp.cloud-handson.com/mcp
→ HMM HR Select AI Agent Tool
```
- JSON 파일명은 이전 배포 호환성을 위해 유지한다. 내용은 KB VPD 사용자가 아닌 HMM HR 데모 사용자다.
- 각 preset은 `mcp_token_env`로 서버 환경변수만 참조한다. token 원문은 절대 JSON에 쓰지 않는다.
- 선택한 사용자는 자연어의 ‘나’, ‘내’, ‘우리 팀’을 해석하는 데모 문맥이다. 현재 HMM MCP의 gateway
token은 공용이므로 이 선택 자체가 DB 행 수준 권한을 강제한다고 표시하지 않는다.
- HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다.
- 호출 인자는 `tools/list`의 schema를 기준으로 생성한다. 기본 도구에도 레거시 `prompt`/`limit`
강제하지 않으며 `search_hr_data`·`search_hr_policy``query`, `resolve_hr_term``term`을 전달한다.
- MCP 호환 서버의 `status/result` 응답에서 `result` 문자열은 정책 본문 근거다. 화면 요약에는
길이와 앞부분을 표시하고, 최종 답변 합성에는 제한된 길이의 원문을 전달한다.
- 사용자 ID·역할·팀 문맥은 `search_hr_data`의 ‘나/우리 팀’ 해석에만 사용한다.
`search_hr_policy``resolve_hr_term`에는 원 질문/원 용어만 전달해 벡터·lexical 검색어를 오염시키지 않는다.
## 런타임 구성
- Streamlit은 `requirements.txt`의 OCI SDK를 `/opt/hmm-poc4/.venv`에 설치해 사용한다.
- OCI Generative AI 인증은 `/home/opc/.oci/config``DEFAULT` profile과 해당 private key를 사용한다.
- `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`
배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다.
- MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다.
- 브라우저의 dark color-scheme과 관계없이 답변 목록, expander header, 보조 버튼도 밝은 배경과
어두운 글자색을 사용한다.
## 완료 기준
1. 팀장 E1001과 팀원 preset이 사이드바에서 선택된다.
2. MCP bearer token은 화면·대화 JSON·Git에 노출되지 않는다.
3. E1001의 ‘우리 팀 대기 휴가’와 E1005/E1007의 ‘내 휴가’가 HMM MCP로 정상 호출된다.
4. 기존 KB MCP·KB VPD 안내가 HMM HR 질문 생성 경로에 사용되지 않는다.
5. OCI SDK가 `DEFAULT` profile로 GPT-5.4-mini를 실제 호출한다.
6. MCP 호출 상세의 JSON·code가 밝은 테마에서 읽힌다.
## 배포 검증
- `/opt/hmm-poc4/config/vpd_token_presets.json`: E1001, E1002, E1003, E1005, E1007 및
`HMM_MCP_BEARER_TOKEN` 간접 참조 확인
- MCP `tools/list`: `search_hr_data`, `resolve_hr_term`, `search_hr_policy` 확인
- E1001 문맥의 한국어 `search_hr_data` 실제 호출 및 UTF-8 응답 확인
- `/opt/hmm-poc4/.venv/bin/python`에서 `openai.gpt-5.4-mini` 실제 호출 확인
- systemd `ExecStart``/opt/hmm-poc4/.venv/bin/python -m streamlit ...`로 고정
- Chromium dark color-scheme에서 MCP JSON 계산값 확인:
배경 `rgb(246, 248, 250)`, 글자 `rgb(23, 43, 58)`
- 정책 질문 브라우저 검증: 12월 31일 기준 사용촉진조치 시 미사용 연차 이월 없음과
입사 2년차부터 차기 발생연차 50% 선사용 제한을 답변했다.
- dark color-scheme 계산값: expander 배경 `rgb(246, 248, 250)`, 보조 버튼 배경
`rgb(255, 255, 255)`, 답변 목록·expander·버튼 글자 `rgb(23, 43, 58)`
## 전 화면 브라우저 검증 기준 (2026-07-23)
다음 화면과 패널을 각각 실제 Chromium에서 열어 렌더링, 오류 alert, 핵심 조작, 색상 대비를 확인한다.
1. 로그인: 입력 필드, 로그인 유지, 실패 안내, 정상 로그인, 로그아웃
2. 아키텍처: 운영 구조와 질의 처리 흐름
3. 시나리오: 샘플 선택, 질문 입력, 저장된 답변, MCP 상세
4. 감사로그: HMM 접근 이벤트 유형·상태·기간 필터, 지표와 이벤트 표
5. 보안관리: 업무 프로세스와 `hmm-backoffice` 운영 포털 링크
6. 사이드바: 데모 사용자 5명, 대화 검색·세션, 대화 관리, MCP 고급 설정
페이지 오류, HTTP 4xx/5xx, 흰 배경의 흰 글자, 검은 배경의 검은 글자, 가로 overflow가 없어야 한다.
### HMM 감사로그 데이터 원천
- 포털 감사로그 탭은 KB PoC의 `POC_2` FGA 카탈로그를 조회하지 않는다.
- DB 접속값은 `POC4_AUDIT_DB_USER`, `POC4_AUDIT_DB_PASSWORD`, `POC4_AUDIT_DSN`으로
분리하고 Git에 값을 저장하지 않는다. ADB가 TLS 서버 인증을 허용하므로 운영 환경은 1521 TLS
접속 기술자를 사용해 Wallet PEM 암호 입력 대기를 제거한다.
- 상호 TLS가 필요한 다른 환경에서는 `POC4_AUDIT_WALLET_DIR`
`POC4_AUDIT_WALLET_PASSWORD`를 함께 지정한다. Wallet 암호 없이 단순 TNS alias만 지정하는
불완전한 설정은 시작 시 안전한 오류로 처리한다.
- 이벤트 원천은 `ADMIN.HMM_ACCESS_AUDIT`이다. 이벤트 유형, 성공/실패, 대상 key/object, 처리 행 수,
오류 코드와 메시지를 최근 순으로 표시한다.
- 비활성 Streamlit tab의 글자·배경색도 공통 presentation CSS에서 명시해 OS dark color-scheme과
무관하게 WCAG AA 수준의 대비를 유지한다.
### 2026-07-23 전수검사 결과
- 실제 Chromium에서 로그인·아키텍처·시나리오·감사로그·보안관리 5개 화면을 각각 열었다.
- 필수 문구 누락 0, 낮은 색상 대비 0, 가로 overflow 0이다.
- 브라우저 page error 0, console error 0, HTTP 오류 0이다.
- 데모 사용자 5명 선택·복원, 대화 검색·JSON 다운로드, MCP discovery cache 갱신을 확인했다.
- 감사로그 필터 2개, 새로고침, 상세 토글이 렌더링되고 `ADMIN.HMM_ACCESS_AUDIT` 0건 상태를
정상적인 empty state로 표시한다.
- 보안관리의 `https://hmm-backoffice.cloud-handson.com/` 링크는 HTTP 200이다.
- 상세 증적은 `docs/reports/2026-07-23-hmm-portal-full-browser-audit.md`에 기록한다.

View File

@@ -2,9 +2,7 @@
## 프로젝트 개요 ## 프로젝트 개요
정식 서비스명은 HMM AI 업무 에이전트이며 현재 소스 경계는 `ai-web-agent-console/`다. `poc4_active_source_20260714`는 레거시 스냅샷 경로이며, 정식 서비스명은 HMM AI 업무 에이전트다. HMM MCP를 통해 HR 데이터, 표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다.
과거 `poc4_active_source_20260714` 스냅샷 경로는 #742에서 제거했다. HMM MCP를 통해 HR 데이터,
표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다.
## 목표 ## 목표
@@ -12,7 +10,7 @@
## 현재 문제 ## 현재 문제
- `ai-web-agent-console/app.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다. - `apps/poc4/mcp_discovery_ui.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다.
- MCP 설정과 인증 토큰 규칙을 수정할 때 화면 코드까지 함께 읽어야 한다. - MCP 설정과 인증 토큰 규칙을 수정할 때 화면 코드까지 함께 읽어야 한다.
- MCP 프로토콜 처리의 단위 검증 지점이 없다. - MCP 프로토콜 처리의 단위 검증 지점이 없다.
@@ -20,15 +18,14 @@
| 모듈 | 책임 | Streamlit 의존 | | 모듈 | 책임 | Streamlit 의존 |
| --- | --- | --- | | --- | --- | --- |
| `ai-web-agent-console/ai_web_agent_console/profile.py` | 제품 프로필과 환경 override 로드 | 없음 | | `src/poc4/runtime_config.py` | `.env`, MCP 서버 JSON, VPD preset 로드와 검증 | 없음 |
| `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py` | MCP 도구 discovery 결과의 route와 arguments 구성 | 없음 | | `src/poc4/mcp_client.py` | endpoint 검증, JSON-RPC, 세션 fallback, tool discovery/call | 없음 |
| `ai-web-agent-console/ai_web_agent_console/auth_gateway.py` | 로그인·쿠키·세션 경계 | 없음 | | `src/poc4/chat_store.py` | SQLite 대화 이력 CRUD | 없음 |
| `ai-web-agent-console/ai_web_agent_console/audit.py` | 감사·증적 조회 경계 | 음 | | `apps/poc4/mcp_discovery_ui.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 음 |
| `ai-web-agent-console/app.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 있음 |
### 재사용 UI Shell과 제품 프로필 ### 재사용 UI Shell과 제품 프로필
공통 화면 shell은 `ai-web-agent-console/ai_web_agent_console/`에서 제공하고, 특정 고객·PoC의 표현은 공통 화면 shell은 `src/agent_console/`에서 제공하고, 특정 고객·PoC의 표현은
`config/app_profile.json`에 둔다. 다른 프로젝트는 앱 코드를 복사·수정하지 않고 profile JSON을 `config/app_profile.json`에 둔다. 다른 프로젝트는 앱 코드를 복사·수정하지 않고 profile JSON을
교체할 수 있다. 실제 배포에서는 `AGENT_CONSOLE_NAME`, `AGENT_CONSOLE_HEADER_DESCRIPTION`, 교체할 수 있다. 실제 배포에서는 `AGENT_CONSOLE_NAME`, `AGENT_CONSOLE_HEADER_DESCRIPTION`,
`AGENT_CONSOLE_PRIMARY_COLOR``AGENT_CONSOLE_*` 환경변수가 JSON 기본값보다 우선한다. `AGENT_CONSOLE_PRIMARY_COLOR``AGENT_CONSOLE_*` 환경변수가 JSON 기본값보다 우선한다.
@@ -51,7 +48,7 @@
2. 변경은 Redmine 이슈에 설계·검증 결과와 Git commit SHA를 함께 기록한다. 2. 변경은 Redmine 이슈에 설계·검증 결과와 Git commit SHA를 함께 기록한다.
3. Git commit message에는 Redmine 번호를 `refs #<번호>:` 형식으로 포함한다. 3. Git commit message에는 Redmine 번호를 `refs #<번호>:` 형식으로 포함한다.
4. 환경별 값과 비밀값은 profile JSON에 넣지 않고 `.env` 또는 secret store에만 둔다. 4. 환경별 값과 비밀값은 profile JSON에 넣지 않고 `.env` 또는 secret store에만 둔다.
5. UI CSS는 `ai-web-agent-console/ai_web_agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다. 5. UI CSS는 `src/agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다.
도메인 로직의 공개 오류는 `PublicMcpError`로 통일한다. UI는 이 오류를 사람이 이해할 수 있는 메시지로 표시하되 토큰과 HTTP 원문을 출력하지 않는다. 도메인 로직의 공개 오류는 `PublicMcpError`로 통일한다. UI는 이 오류를 사람이 이해할 수 있는 메시지로 표시하되 토큰과 HTTP 원문을 출력하지 않는다.

View File

@@ -1,7 +1,7 @@
# HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702) # HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702)
> 상태: Implemented > 상태: Implementing
> 추적: Redmine #702 / Git 브랜치: `hmm-backoffice` > 추적: Redmine #702
> 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN` > 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN`
## 목적 ## 목적
@@ -20,8 +20,6 @@
| 역할 | `HMM_ACCESS_ROLES`, `HMM_EMPLOYEE_ACCESS_ROLES`, `HMM_GROUP_ACCESS_ROLES` | 직원 직접 역할과 그룹 상속 역할을 분리 | | 역할 | `HMM_ACCESS_ROLES`, `HMM_EMPLOYEE_ACCESS_ROLES`, `HMM_GROUP_ACCESS_ROLES` | 직원 직접 역할과 그룹 상속 역할을 분리 |
| 토큰 | `HMM_ACCESS_BEARER_TOKENS` | 원문은 발급 화면에서 한 번만 표시하고 SHA-256 해시만 저장 | | 토큰 | `HMM_ACCESS_BEARER_TOKENS` | 원문은 발급 화면에서 한 번만 표시하고 SHA-256 해시만 저장 |
| 감사 | `HMM_ACCESS_AUDIT` | 사용자·그룹·역할·토큰 변경 이력 저장 | | 감사 | `HMM_ACCESS_AUDIT` | 사용자·그룹·역할·토큰 변경 이력 저장 |
| 지식 문서 | `HMM_KNOWLEDGE_DOCUMENTS` | 파일명·원문·BLOB·Abstract·생성일자를 저장 |
| 지식 청크 | `HMM_KNOWLEDGE_CHUNKS`, `HMM_KNOWLEDGE_TAGS` | 문서별 청크와 정규화된 Tag·벡터를 저장 |
`HMM_ORG_TEAMS`는 인사 조직 원장이므로 접근 그룹과 혼합하지 않는다. 이로써 한 직원이 여러 `HMM_ORG_TEAMS`는 인사 조직 원장이므로 접근 그룹과 혼합하지 않는다. 이로써 한 직원이 여러
업무 접근 그룹에 속하면서도 인사 소속팀은 하나로 유지된다. 업무 접근 그룹에 속하면서도 인사 소속팀은 하나로 유지된다.
@@ -37,8 +35,6 @@ HMM_HR_EMPLOYEES ──< HMM_EMPLOYEE_ACCESS_ROLES >── HMM_ACCESS_ROLES
HMM_HR_EMPLOYEES ──< HMM_ACCESS_BEARER_TOKENS HMM_HR_EMPLOYEES ──< HMM_ACCESS_BEARER_TOKENS
HMM_ACCESS_AUDIT records every backoffice change HMM_ACCESS_AUDIT records every backoffice change
HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS
``` ```
## 화면 및 호환성 ## 화면 및 호환성
@@ -47,72 +43,17 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS
`employment_status``ACTIVE`/`INACTIVE`에 매핑한다. `employment_status``ACTIVE`/`INACTIVE`에 매핑한다.
- `/groups``HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록 - `/groups``HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록
논리 접근 그룹임을 화면에 명시한다. 논리 접근 그룹임을 화면에 명시한다.
- `/roles`는 HMM 접근 역할만 관리한다. - `/roles`는 HMM 접근 역할만 관리한다. 기존 VPD 객체 권한 규칙은 이번 범위에서 노출하지 않는다.
- `/tokens`는 HMM 직원에게 토큰을 발급·회수한다. KB 이해당사자 원장은 참조하지 않는다. - `/tokens`는 HMM 직원에게 토큰을 발급·회수한다. KB 이해당사자 원장은 참조하지 않는다.
- `/objects`, `/permissions`, `/masking-rules`, `/probe`, MCP 화면은 삭제하지 않는다. 기존 CB 화면
계약은 `CB_PROTECTED_*`, `CB_PERMISSION*`, `CB_VECTOR_*` 호환 뷰로 유지하고, 실제 데이터는
`HMM_ACCESS_*``HMM_KNOWLEDGE_*`에 저장한다.
- 애플리케이션 시작 시 `HmmKnowledgeSchemaInitializer`가 HMM 지식 테이블·시퀀스·인덱스를
멱등적으로 준비하고 `CB_VECTOR_*` 조회 호환 뷰를 갱신한다. 기존 물리 `CB_VECTOR_*` 테이블이
존재하는 환경은 덮어쓰지 않는다.
### 재사용 가능한 정형 원장 카탈로그
- `/structured-data``/schema-metadata`의 데이터 원본명, Oracle owner, 안내 문구, 최대 조회 건수,
허용 테이블 목록은 Java·HTML에 하드코딩하지 않는다.
- 기본 HMM 정의는 `vpd-backoffice/src/main/resources/config/structured-data-catalog.json`에 둔다. 배포 환경에서는
`BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/.../structured-data-catalog.json`으로 외부
JSON을 지정할 수 있어 다른 회사 PoC에서 애플리케이션 코드를 수정하지 않고 재사용할 수 있다.
- 각 테이블 정의는 `key`, `tableName`, `businessName`, `description`을 기본으로 하고 필요하면
`previewColumns`, `maskingPolicyName`을 추가한다. 화면 카드, metadata 대상, 실제 SELECT 허용 목록,
ASO 관리 대상은 모두 이 단일 JSON을 기준으로 생성한다. VECTOR/BLOB 등 JDBC 원장 미리보기에
부적합한 컬럼은 `previewColumns`에서 제외하되 DB 메타데이터 화면에는 계속 표시한다.
- owner와 table name은 Oracle 단순 식별자 규칙, key는 URL key 규칙으로 시작 시 검증한다. 중복 key,
중복 table, 빈 목록, 허용 범위를 벗어난 row limit은 기동 실패로 처리해 동적 SQL 범위를 닫는다.
- HMM 기본 카탈로그는 `ADMIN`의 조직, 직원, 휴가 잔여, 휴가 신청, 일별 근태, HR 표준 용어 원장만
노출한다. 기존 `KBAIPOC`, `POC_2`, `KB_*` 보험 원장은 HMM 배포 카탈로그에 포함하지 않는다.
## HMM MCP 및 시스템 설정
- 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은
사용자 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로
도메인 변경 시 화면 소스를 수정하지 않는다.
- HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`,
`search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`,
`HMM_HR_NORMALIZED_DATA_SEARCH`, `HMM_HR_POLICY_SEARCH`만 읽기 전용으로 실행한다.
- `ORDS_BASE_URL`은 기존 VPD/ORDS 접근 검증과 Handler 관리용 선택 설정이다. HMM HR 질의 및
Agent Factory MCP 호출에는 사용하지 않는다. 비워 두면 레거시 ORDS 기능만 미설정 상태가 된다.
- HMM 기동 시 과거 KB 데모의 오사카 ORDS 주소가 정확히 저장된 경우에만 제거한다. 운영자가 별도로
지정한 다른 ORDS 주소는 보존한다.
## 브랜치·배포 기준
- Smilegate 기준은 `main`이며, HMM 백오피스의 구현·배포·검증은 `hmm-backoffice` 브랜치만 사용한다.
- HMM 배포 전에 현재 브랜치명을 확인하고, `main` 또는 `hmm-ai-agent` 브랜치에서는 HMM 백오피스
JAR를 배포하지 않는다.
- 메뉴 전수 점검은 로그인 세션으로 원래 메뉴 URL 전체를 순회한다. HTTP 200만으로 통과시키지 않고
화면 내 `ORA-`, `데이터 처리 오류`, `Whitelabel Error Page`도 함께 검사한다.
## 보안·검증 ## 보안·검증
- 토큰 원문, DB 비밀번호, Wallet은 테이블·Git·로그에 저장하지 않는다. - 토큰 원문, DB 비밀번호, Wallet은 테이블·Git·로그에 저장하지 않는다.
- DDL은 재실행 가능해야 하며 기존 HR 행을 수정하거나 삭제하지 않는다. - DDL은 재실행 가능해야 하며 기존 HR 행을 수정하거나 삭제하지 않는다.
- 배포 전 전체 자동 테스트를 통과시키고, 배포 후 사용자·접근 그룹·역할·토큰뿐 아니라 보호 객체, - 배포 전 SQLcl로 테이블·FK·시드 역할을 확인하고, 배포 후 사용자·그룹·역할·토큰 화면을 HTTP
권한, 마스킹, 접근 검증, 지식자료, MCP, 운영 메뉴를 로그인 세션으로 전수 확인한다. 준에서 확인한다.
## 롤백 ## 롤백
애플리케이션은 이전 JAR로 되돌릴 수 있다. 새 `HMM_ACCESS_*` 테이블은 운영 데이터가 생긴 뒤에는 애플리케이션은 이전 JAR로 되돌릴 수 있다. 새 `HMM_ACCESS_*` 테이블은 운영 데이터가 생긴 뒤에는
삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다. 삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다.
## 2026-07-23 배포·전수 검증
- 운영 JSON: `/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json`
- systemd override: `20-structured-data-catalog.conf`
- 자동 테스트: `mvn test` 103건 통과
- 브라우저 검증: 로그인 세션으로 메뉴 URL 26개 전부 HTTP 200, 오류 alert 0건, `ORA-` 0건,
`KBAIPOC`/`POC_2`/KB 보험원장 표시 0건, page error 0건, console error 0건
- 정형 원장 실데이터: 조직 1행, 직원 7행, 휴가 잔여 7행, 휴가 신청 8행, 일별 근태 14행,
HR 표준 용어 21행 렌더링 확인
- 용어 원장의 VECTOR 컬럼은 `previewColumns`에서 제외해 관리자 미리보기에는 사람이 읽을 수 있는
표준 코드·명칭·설명·임베딩 시각만 표시하고, DB 메타데이터 관리에서는 전체 컬럼을 유지한다.

View File

@@ -0,0 +1,87 @@
# 설계서: 스마일게이트 백오피스 잔여 UI 전환 및 운영 검증
## 추적성
- Redmine: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
- 관련 설계: `docs/design/smilegate-demo-rebranding/README.md`, `docs/design/smilegate-identity-administration/README.md`
- 구현 대상: `src/main/resources/templates/`, `src/main/resources/static/js/app.js`, `src/main/java/com/cloudhandson/vpdbackoffice/`, `poc4_active_source_20260714/`
- 검증 대상: Maven·Streamlit 설정 테스트, 인증 후 핵심 메뉴 HTTP 응답, 화면의 잔여 고객사 문구 검사
- 상태: Implemented / deployment pending
## 프로젝트 개요
이 저장소의 Spring Boot 백오피스는 Oracle VPD, Data Redaction, FGA, ORDS 및 Select AI PoC의 운영 설정을 확인하고 관리한다. 공개 데모의 고객·업무 대상은 스마일게이트 게임 로그와 서비스 데이터 분석이다.
## 목표
백오피스에 남은 KB손해보험/보험 업무 예시를 스마일게이트 게임 데이터 기준으로 전환한다. 화면 문구만 바꾸지 않고, 실제 MCP Select AI 안내·행 접근 규칙 요약·마스킹 동기화 대상도 `SGMP_POC` 게임 데이터와 모순되지 않게 맞춘다.
## 범위
1. 웹 화면과 브라우저에서 실행되는 JavaScript에 노출된 기존 보험 업무 예시를 게임 사용자·게임 서비스·판매/환불 데이터 예시로 교체한다.
2. 권한 규칙의 표시명과 미리보기는 기존 조건 코드의 저장 형식을 보존하면서 게임 데이터 의미로 설명한다.
3. MCP 데모의 tool 식별자·설명·질의 예시를 `SGMP_POC` Select AI 프로파일 기반으로 전환한다. 행 접근 토큰을 전제로 하는 기존 KB ORDS endpoint를 게임 데이터 endpoint인 것처럼 표시하지 않는다.
4. Data Redaction 동기화는 `SGMP_POC`의 실제 게임 사용자·판매 데이터 컬럼만 관리 대상으로 삼는다.
5. 내부 호환용 `CB_*` 뷰와 과거 SQL 이력은 실행 경로에서 제외한다. Smilegate 공개 화면·MCP 설정은 이력의 고객 데이터나 endpoint를 참조하지 않는다.
6. Streamlit 외피는 Smilegate 프로필·게임 데이터 시나리오·`oracle.select_ai.smilegate_game_text2sql` MCP 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
7. `/schema-metadata`의 테이블 comment·컬럼 comment·annotation 조회와 저장 DDL은 모두 `SchemaMetadataMapper`로 수행한다. 메타데이터 조회는 `SGMP_POC` owner와 허용된 테이블 목록으로 한정한다.
## 설계 결정
### 1. 업무 용어는 데이터 모델의 사실에 맞춘다
- 사용자 식별자: `CZN_COMN_USER_MST.GUID`/`AUID`, `COMN_SALES_USER_MST.USER_KEY_VAL`
- 게임 서비스 식별: `COMN_GAME_ALIAS_BAS``GAME_ID`, `GAME_PREFIX`, `GAME_NM`, `GAME_ALIAS_NM`
- 거래/서비스 데이터: `COMN_SALES_TXN`, `COMN_REFUND_TXN`, `CZN_CUSTOM_*`
화면 예시는 위 객체를 사용하되, 실제로 존재하지 않는 담당자·채널 컬럼을 SQL 예시로 만들지 않는다.
### 2. 조건 코드의 호환성과 표시 의미를 분리한다
`OWN_CONTRACT`, `CHANNEL_CONTRACT`, `OWN_CUSTOMER`, `CHANNEL_CUSTOMER` 같은 과거 코드값은 저장값 호환을 위해 유지한다. 화면에는 각각 `담당 게임 서비스`, `토큰 채널 게임 서비스`, `담당 게임 사용자 데이터`, `토큰 채널 게임 사용자 데이터`로 표시한다. VPD 구현이 게임 데이터에 대한 실제 관계를 갖지 않는 조건은 설명에서 일반적인 보안 범위 조건으로만 제시하고, 존재하지 않는 조인 SQL을 제안하지 않는다.
### 3. MCP/Select AI는 현재 실행 경계를 정직하게 표시한다
MCP tool은 `SGMP_POC_HAIKU45` 프로파일을 기준으로 게임 데이터의 읽기 전용 `SELECT`/`WITH` 질의를 **생성**하는 용도로 안내한다. 생성 단계는 `SHOWSQL`만 사용하며 모델이 만든 SQL을 백오피스가 자동 실행하지 않는다. 운영자는 Database Actions 또는 검증된 실행 경로에서 SQL을 검토·실행한다.
프로파일은 `SGMP_POC` 소유이므로 일반 백오피스 관리 DB 연결(ADMIN)에서 사용할 수 없다. MCP Text2SQL 서비스는 별도 `BACKOFFICE_SELECT_AI_DB_URL`, `BACKOFFICE_SELECT_AI_DB_USERNAME`, `BACKOFFICE_SELECT_AI_DB_PASSWORD` 환경 변수로 `SGMP_POC` 연결을 만들고, 설정이 없을 때는 명확한 설정 오류만 반환한다. 비밀 값은 Git·화면·로그에 저장하지 않는다.
호출 전에 백오피스의 Bearer 토큰 해시를 검증하고 활성 사용자 토큰에만 Text2SQL 요청을 허용한다. 현재 PoC의 두 데모 운영 사용자는 게임 데이터 전체 권한을 갖지만, 후속 권한 세분화 시 이 지점에 역할별 데이터 범위 검증을 추가한다.
### 4. 마스킹 대상은 관리 가능한 실제 객체로 제한한다
마스킹 동기화 대상 owner는 `SGMP_POC`다. 관리 정책은 실제 컬럼 존재 여부를 검증한 뒤 사용자 식별자와 거래 사용자 식별자에만 적용한다. 대상에 없는 규칙은 DBMS_REDACT 호출 전에 화면 설정 오류로 처리한다.
### 5. 스키마 메타데이터 접근은 MyBatis로 통일한다
`/schema-metadata`는 화면 카드 목록을 정적 허용 목록에서 만들고, 선택된 테이블의 comment·컬럼·annotation만 조회한다. 서비스 계층에는 JDBC 직접 실행을 두지 않는다. table/column comment 사전 조회는 `owner = 'SGMP_POC'` 조건을 갖는다. Oracle의 `ALL_ANNOTATIONS_USAGE`에는 객체 owner 컬럼이 없으므로 annotation 조회는 허용 목록에서 선택된 정확한 `OBJECT_NAME``OBJECT_TYPE='TABLE'`로 한정한다. DDL에 쓰이는 테이블·컬럼·annotation 이름은 호출 전에 대문자 식별자 규칙과 허용 테이블 목록으로 검증한다.
백오피스 도메인은 Caddy에서 `gzip`만 사용한다. Edge가 HTTP/2 `zstd` HTML 응답을 완료된 상태에서도 pending으로 표시한 운영 증거가 있어, Streamlit 콘솔과 분리해 관리 UI 응답의 압축 호환성을 우선한다.
공통 화면 head의 외부 UI 보조 스크립트는 `defer`로 로드한다. CDN 지연이 정적 서버 렌더링 화면의 HTML 파싱·첫 표시를 막아서는 안 된다. `schema-metadata`는 서버 렌더링만으로 테이블 선택과 comment/annotation 보기를 제공한다.
## 변경 파일과 책임
| 영역 | 파일 | 변경 |
| --- | --- | --- |
| 행 접근 화면 | `templates/permissions.html`, `static/js/app.js`, `PermissionView.java` | 보험 용어와 존재하지 않는 KB SQL 예시 제거 |
| 마스킹 화면 | `templates/masking-rules.html`, `templates/user-masking-rules.html`, `MaskingPolicySynchronizer.java` | 게임 데이터 예시 및 실제 `SGMP_POC` 관리 대상 사용 |
| VPD/운영 화면 | `templates/vpd-filter-runtime.html`, `templates/operation-status.html` | 게임 데이터 상태 표시 예시 적용 |
| MCP 화면 | `templates/mcp-sse.html`, `McpSseService.java`, `SmilegateSelectAiService.java` | 게임 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 |
| 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 |
| Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 |
| 스키마 메타데이터 | `SchemaMetadataService.java`, `SchemaMetadataMapper.java`, `SchemaMetadataMapper.xml` | 직접 JDBC 제거, MyBatis 조회·DDL 통일, `SGMP_POC` owner 조건 강제 |
## 완료 기준
1. Smilegate 공개 화면·활성 MCP 설정에서 기존 고객사명·보험 원장·기존 endpoint가 검색되지 않는다. 과거 SQL 이력 및 미실행 호환 코드는 제외한다.
2. `SGMP_POC` 게임 데이터 객체만 마스킹 동기화 대상으로 선택된다.
3. `mvn test`가 통과한다.
4. 인증된 `admin`으로 주요 메뉴가 오류 배너 없이 200 응답을 반환하고, Streamlit의 MCP는 Text2SQL 생성 결과를 정상 표기한다.
5. 변경 사항은 #703을 참조하는 Git 커밋과 Redmine 작업 로그로 남긴다.
## 위험 및 완화
- 과거 KB ORDS API는 게임 데이터 정책을 보장하지 않는다. endpoint 이름만 치환해 기존 API를 재사용하지 않는다.
- 운영 VM SSH 키 인증이 거부될 수 있다. 로컬 빌드·공개 URL 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.

View File

@@ -0,0 +1,43 @@
# 설계서: Smilegate 전용 브랜치 분리
## 추적성
- Redmine: #704 `[Release] Smilegate 전용 브랜치 분리`
- 관련 이슈: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
- 작업 경로: `/Users/joungminko/claude-workspace/vpd-smilegate-rebrand`
- 원격: `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git`
- 상태: Draft
## 프로젝트 개요
`vpd-permission-poc`은 Spring Boot VPD 관리 백오피스를 포함한다. HMM과 Smilegate 데모는 현재 같은 원격 저장소를 사용하지만, 고객별 화면·데이터 모델·배포 기준은 분리돼야 한다.
## 목표
Smilegate 작업본을 원격 `smilegate` 브랜치로 분리한다. HMM은 기존 `main` 브랜치를 그대로 사용하고, Smilegate 변경은 `smilegate` 브랜치만 기준으로 커밋·푸시·배포한다.
## 범위
1. detached HEAD 상태의 Smilegate worktree에서 `smilegate` 브랜치를 생성한다.
2. `origin/smilegate`를 생성하고 현재 worktree의 upstream으로 설정한다.
3. #703의 Smilegate 전용 설계서와 UI 변경만 `smilegate` 브랜치에 기록한다.
4. HMM 작업본, `origin/main`, 다른 worktree의 파일과 HEAD를 변경하지 않는다.
## 비범위
- HMM의 로컬 수정·브랜치·배포 변경
- 기존 `main`의 이력 재작성 또는 강제 푸시
- 원격 저장소를 새로 생성하거나 삭제하는 작업
## 검증 기준
1. `git branch --show-current`은 Smilegate worktree에서 `smilegate`를 반환한다.
2. `git rev-parse --abbrev-ref @{u}``origin/smilegate`를 반환한다.
3. `origin/main`의 커밋 ID는 분리 전후 동일하다.
4. HMM 작업본의 status와 HEAD는 분리 작업으로 변경되지 않는다.
## 운영 규칙
- Smilegate 배포는 `origin/smilegate`의 검증된 커밋만 사용한다.
- HMM 변경은 `main` 또는 HMM 전용 작업 경로에서만 수행한다.
- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.

View File

@@ -0,0 +1,70 @@
# 설계서: 스마일게이트 고객 질답 검증 이력
## 추적성
- Redmine: #706 `[Smilegate] 고객 엑셀 질답 검증 이력 및 실행 화면`
- 기준 질답서: `/Users/joungminko/claude-workspace/oci-data-flow-aidp/docs/reports/sgmp-select-ai-full-qa-term-dict-final-v2-20260721.md`
- 기준 데이터: 표준 DW 샘플 28건 + 카제나 샘플 19건 = 47건
- 대상 스키마: `SGMP_POC`
- 대상 화면: `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py`
## 프로젝트 개요
`vpd-permission-poc`은 Oracle Autonomous Database의 게임 데이터와 Select AI/MCP를 연결해 자연어 데이터 질의를 검증하는 PoC다. 이번 기능은 고객이 제공한 Excel 기반 질답서를 실행 가능한 기준 시나리오로 바꾸고, 데모 중 실제 답변 품질을 설명 가능하게 남긴다.
## 목표
1. 고객 Excel에서 정리한 47개 질문을 질문 마스터로 보관한다.
2. 기준 답변, 기준 SQL, 과거 검증 결과와 이후 실행 결과를 모두 순차 이력으로 보관한다.
3. 사용자가 후보 테이블에서 질문을 고르거나 자유 질의를 입력해 즉시 실행할 수 있게 한다.
4. 후보 질문은 SQL 의미 검증과 실행 결과로 `PASS`, `WARN`, `FAIL`을 표시한다. 정답 기준이 없는 자유 질의는 `REVIEW`로 표시한다.
## 데이터 모델
테이블은 사용자 요청에 따라 두 개만 둔다.
| 테이블 | 키 | 역할 |
| --- | --- | --- |
| `SG_AI_QA_QUESTION` | `QUESTION_ID` | 고객 Excel 질문, 출처, 기대 포인트, 원본 샘플 SQL, 기준 SQL/답변, SQL 판정 규칙을 보관한다. 자유 질의도 해시 기준으로 이 테이블에 한 번만 등록한다. |
| `SG_AI_QA_ANSWER` | `ANSWER_SEQ` | 질문별 실행 이력이다. 과거 47건도 `HISTORICAL`로 적재하고, 포털 실행은 `LIVE`로 계속 추가한다. |
`SG_AI_QA_ANSWER.QUESTION_ID`는 질문 마스터를 참조한다. 실행 결과는 JSON, 생성 SQL·답변·판정 근거는 CLOB으로 저장한다. 따라서 질문 기준은 바뀌어도 이미 실행된 이력의 원문과 당시 판정을 보존한다.
## 판정 규칙
1. 기준 시나리오는 `required_sql_terms``recommended_sql_terms`를 사용한다.
2. 필수 테이블·컬럼·집계·기간 규칙이 빠지거나 모델 오류 문구가 SQL에 섞이면 `FAIL`이다.
3. 권장 필터가 빠졌거나 지원 범위가 일부인 경우 `WARN`이다.
4. 미지원 게임 질문은 별칭 조회를 거치지 않고 임의 게임 ID나 테이블을 만들어 내면 `FAIL`이다. 안전하게 거절하거나 별칭 조회 결과가 0건이면 `PASS`이다.
5. 월간 NRU/AU, 재화 보유/사용 등 기존 질답서의 개별 보정 규칙은 같은 판정기에 반영한다.
6. 자유 질의는 기준 질문을 선택하지 않은 경우 `REVIEW`로 저장한다. 실행 성공을 정답으로 표시하지 않는다.
문장 표현의 유사도만으로 정답을 판정하지 않는다. 집계값, 생성 SQL, 실행 결과가 근거가 되므로 고객에게 왜 통과 또는 실패인지 보여줄 수 있다.
## 화면 흐름
1. `검증 시나리오` 탭에서 47개 후보를 표 형태로 표시한다. 케이스, 구분, 제목, 질문, 기대 포인트, 최근 판정, 최근 실행 시각을 보여 준다.
2. 행을 선택하면 질문 입력란이 채워지고, 우측 또는 하단에 기준 답변·기준 SQL·원본 Excel 출처를 표시한다.
3. 사용자는 선택된 기준 질문을 그대로 실행하거나 자유 텍스트를 작성한다.
4. 실행 뒤에는 현재 답변, 생성 SQL, 조회 행, 판정, 판정 근거를 표시하고 `SG_AI_QA_ANSWER`에 저장한다.
5. 같은 질문의 과거 답변은 최신 순 표로 보여 주며, 과거 기준 검증과 현재 실행을 구분한다.
## 적재 기준
- 기준 원본은 `sgmp-select-ai-full-qa-term-dict-final-v2-20260721.md`와 동시 생성된 JSON이다.
- JSON의 `STD-05` 실행 출력은 비정상적으로 크므로, 이력 조회 안정성을 위해 저장 시 안전한 길이로 절단하고 원본 보고서 경로를 질문에 남긴다.
- 과거 레코드는 `HISTORICAL`, 포털에서 수행하는 새 레코드는 `LIVE`로 구분한다.
## 완료 기준
- ADB에 질문 마스터 47건과 과거 답변 이력 47건이 있다.
- 답변 이력 키는 증가하는 `ANSWER_SEQ`이며 질문 외래키가 유효하다.
- 후보 선택, 자유 질의, 기준 답변/SQL, 과거 이력, PASS/WARN/FAIL/REVIEW 표기가 한 화면에서 작동한다.
- 생성 SQL의 핵심 규칙을 바꾼 실패 케이스가 `FAIL`로 판정되는 단위 테스트가 있다.
- 실제 포털 실행 한 건이 ADB 이력에 저장되는 것을 확인한다.
## 비범위
- 이 기능은 Select AI의 정답을 하드코딩해 바꾸지 않는다.
- 과거 대화 SQLite 저장소를 이번 작업에서 전면 이전하지 않는다. 고객 질답 검증 이력만 ADB의 두 테이블에 저장한다.
- 자유 질의에 임의의 정답을 부여하지 않는다.

View File

@@ -1,116 +0,0 @@
# HMM 휴가 원장 팀 계층 VPD 설계 (#707)
> 상태: 구현·ADB 적용·배포·검증 완료
> 대상: HMMAIPOC `ADMIN`, `hmm-backoffice.cloud-handson.com`
> Git 기준: `hmm-backoffice`
## 목적
HMM 백오피스의 사용자·역할·보호 객체·행 접근 규칙을 실제 Oracle VPD와 연결한다.
팀장 E1001은 자신과 직접 보고 팀원의 휴가 잔여 및 휴가 신청을 조회하고, 일반 팀원은 자기 행만
조회한다. 유효한 사용자 컨텍스트나 허용 규칙이 없으면 모든 행을 차단한다.
## 조사 결과
- 직원 7명은 `HMM_HR_EMPLOYEES`에 있으며 E1001의 직접 보고 관계가
`MANAGER_EMPLOYEE_ID = 1`로 저장되어 있다.
- `HMM_LEAVE_BALANCES`는 직원별 1행씩 총 7행이고, `HMM_LEAVE_REQUESTS`는 팀원 6명의 총 8행이다.
- HMM 역할 `HMM_HR_VIEWER`, `HMM_HR_MANAGER`, `HMM_HR_ADMIN`은 존재한다.
- 적용 전 사용자 역할, HMM 토큰, 보호 객체, 행 접근 규칙, VPD 정책은 0건이었다.
- `ADMIN``EXEMPT ACCESS POLICY` 권한이 있어 정책 적용 여부를 검증할 때는 별도의 비면제
검증 사용자가 필요하다.
## 접근 정책
| 사용자 유형 | 역할 | 저장 규칙 | 결과 |
|---|---|---|---|
| 일반 팀원 | `HMM_HR_VIEWER` | `SELF` | 현재 직원 ID와 같은 `EMPLOYEE_ID` 행 |
| 팀장 | `HMM_HR_MANAGER` | `MANAGED_TEAM` | 현재 직원 본인과 `MANAGER_EMPLOYEE_ID`가 현재 직원인 직접 보고자 행 |
| HR 관리자 | `HMM_HR_ADMIN` | `ALL` | 전체 행. 역할이 실제로 부여된 경우에만 적용 |
| 미인증/무권한 | 없음 | 없음 | `1 = 0` |
E1001에는 `HMM_HR_MANAGER`, E1002~E1007에는 `HMM_HR_VIEWER`를 부여한다. HR 관리자 역할은
미리 규칙만 준비하고 현재 데모 사용자에게 자동 부여하지 않는다.
## DB 구성
### 컨텍스트
`HMM_ACCESS_CTX``HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`만이 설정한다.
패키지는 전달된 토큰의 SHA-256 해시를 `HMM_ACCESS_BEARER_TOKENS`와 비교하고 다음 조건을 모두
만족한 경우에만 직원 ID, 사번, 팀 ID를 세션 컨텍스트에 기록한다.
- 토큰이 회수되지 않음
- 만료 시각이 현재보다 이후
- 직원이 `ACTIVE`
오류 또는 누락 토큰은 컨텍스트를 먼저 지우고 실패한다.
### 정책 함수
`HMM_LEAVE_VPD_FILTER`는 현재 컨텍스트의 직원에게 직접 또는 접근 그룹으로 부여된 활성 역할을
구한다. 역할과 `CB_PERMISSION`, `CB_PERMISSION_RULE`에 저장된 대상별 규칙을 결합해
`HMM_LEAVE_BALANCES``HMM_LEAVE_REQUESTS``EMPLOYEE_ID` 조건을 반환한다.
- 한 권한 안의 규칙은 `AND`
- 서로 다른 허용 권한은 `OR`
- 거부 규칙은 허용 결과에서 제외
- 알 수 없는 규칙, 존재하지 않는 컬럼, 빈 컨텍스트는 fail-closed
### 정책 연결
두 휴가 테이블에 `HMM_LEAVE_SCOPE_POLICY``SELECT`, `CONTEXT_SENSITIVE`로 연결한다.
정책 메타데이터와 설명은 백오피스의 보호 객체 및 VPD 화면에서 조회한다.
## 백오피스 변경
- `/permissions`가 HMM 역할과 `CB_PERMISSION*` 호환 저장소의 실제 규칙을 표시한다.
- HMM 행 규칙 선택지는 `ALL`, `MANAGED_TEAM`, `SELF`, `STATIC_SQL`을 우선 제공한다.
- 미리보기는 `HMM_ACCESS_CTX.EMPLOYEE_ID`와 직접 보고 관계를 보여준다.
- 휴가 테이블의 기본 정책 연결은 `HMM_LEAVE_VPD_FILTER`를 선택한다.
- 보호 객체·역할·권한 수정은 기존 감사 및 외부 권한 변경 알림 흐름을 유지한다.
## 배포 순서
1. 애플리케이션 테스트와 SQL 구문 검사를 수행한다.
2. 멱등 SQL `database/adb/72_hmm_leave_team_vpd.sql``ADMIN`으로 실행한다.
3. VPD 정책·컴파일 상태·백오피스 화면을 확인한다.
4. 백오피스와 같은 SHA-256 저장 규칙으로 E1001과 E1002 임시 검증 토큰을 만든다.
5. 비면제 검증 사용자에서 토큰별 조회 결과를 확인하고 검증 토큰과 사용자를 제거한다.
6. 애플리케이션 JAR를 배포한 뒤 전 메뉴 회귀검사를 수행한다.
## 완료 기준
- E1001: 휴가 잔여 7행, 휴가 신청 8행
- E1002: 휴가 잔여 1행, 휴가 신청 1행
- E1002가 E1003의 행을 조건에 추가 조회해도 0행
- 무컨텍스트: 두 테이블 모두 0행
- 두 정책과 컨텍스트/패키지/함수가 `VALID``ENABLE=YES`
- `/users`, `/roles`, `/objects`, `/permissions`, `/vpd-policies`, `/vpd-filter-runtime`,
`/tokens`, `/probe`를 포함한 백오피스 메뉴에 데이터 처리 오류가 없음
## 검증 결과
2026-07-23 배포에서 모든 완료 기준을 충족했다.
- `HMM_ACCESS_CTX_PKG``HMM_LEAVE_VPD_FILTER``VALID`
-`HMM_LEAVE_SCOPE_POLICY``CONTEXT_SENSITIVE`, `ENABLE=YES`
- 비면제 임시 DB 사용자와 원문을 저장하지 않는 임시 토큰으로 확인:
- 무컨텍스트: 잔여 0행, 신청 0행
- E1001 팀장: 잔여 7행, 신청 8행
- E1002 팀원: 잔여 1행, 신청 1행
- E1002가 E1003 신청을 조건 조회: 0행
- 무효 토큰: 0행
- 검증용 DB 사용자와 임시 토큰은 즉시 제거했다.
- 자동 테스트 105건이 통과했다.
- 실제 브라우저로 관리 화면 26개와 HMM 정형 원장 6개를 전수 확인했으며 HTTP/화면/브라우저
오류는 0건이었다.
상세 증거는 `docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md`에 기록한다.
## 보안 및 롤백
- 토큰 원문과 DB 비밀번호를 SQL, Git, 로그에 기록하지 않는다.
- 검증용 DB 사용자와 검증 토큰은 테스트가 끝나면 제거 또는 회수한다.
- 롤백은 두 `HMM_LEAVE_SCOPE_POLICY`를 비활성화하거나 제거한 뒤 사용자 역할·권한 메타데이터를
유지하는 방식으로 수행한다. 휴가 원장 데이터는 수정하거나 삭제하지 않는다.

View File

@@ -0,0 +1,48 @@
# #708 Smilegate Select AI OCI GenAI 전환
## 프로젝트 개요
Smilegate DATA & AI PoC는 Autonomous Database의 게임 데이터에서 Select AI Text2SQL을 생성하고, MCP와 백오피스를 통해 결과를 조회한다. 현재 운영 프로파일은 외부 OpenRouter 경유 Claude를 사용한다.
## 목표
`SGMP_POC_HAIKU45`가 가진 게임 데이터 object list와 메타데이터 활용 범위는 유지하면서, LLM 호출 경로만 OCI Generative AI `openai.gpt-5.4-mini`로 전환한다. ADB Resource Principal이 아닌 이 머신의 `~/.oci/config` DEFAULT API signing credential을 ADB credential으로 등록한다.
## 전환 설계
| 구분 | 기존 | 전환 후 |
| --- | --- | --- |
| 프로파일 | `SGMP_POC_HAIKU45` | `SGMP_POC_OCI_GPT54MINI` |
| 제공자 | OpenAI 호환 외부 경로 | OCI Generative AI (`provider: oci`) |
| 모델 | Claude Haiku 4.5 | `openai.gpt-5.4-mini` |
| 인증 | 외부 API credential | DEFAULT API signing key 기반 `SGMP_POC_OCI_DEFAULT_CRED` |
| OCI 리전 | 외부 서비스 | `us-chicago-1` (GPT-5.4 Mini OCI inference route) |
| 게임 데이터 범위 | 기존 object list | 기존 profile attributes에서 복제 |
| MCP 설정 | 기존 프로파일명 | `BACKOFFICE_SELECT_AI_PROFILE=SGMP_POC_OCI_GPT54MINI` |
신규 프로파일은 기존 프로파일의 metadata 관련 attributes를 복사하고, 외부 endpoint·credential·model은 OCI 값으로 새로 설정한다. 따라서 object list, comment, annotation, constraint 기반 Text2SQL 문맥은 유지된다. 기존 외부 프로파일은 삭제하지 않으며, 전환 실패 시 환경변수만 원래 값으로 되돌린다.
## 사전 조건
1. `~/.oci/config` DEFAULT의 user, tenancy, fingerprint, key_file이 유효한 OCI API signing key여야 한다.
2. DEFAULT API signing user가 Chicago 리전 root compartment에서 OCI Generative AI `openai.gpt-5.4-mini` 호출 권한을 가져야 한다. DEFAULT config의 signing region과 GPT inference route는 독립적이므로 profile attribute `region``us-chicago-1`로 명시한다.
3. 스크립트는 `SGMP_POC` 프로파일 소유자로 실행한다.
## 구현 순서
1. `sql/adb/72_sgmp_select_ai_oci_genai_profile.sql`로 신규 OCI 프로파일을 만든다.
2. 새 프로파일로 한글 Text2SQL `SHOWSQL`과 생성 SQL의 읽기 전용 실행을 검증한다.
3. 운영 서버의 `BACKOFFICE_SELECT_AI_PROFILE`만 새 프로파일로 교체하고 백오피스를 재기동한다.
4. 운영 MCP의 `oracle.select_ai.smilegate_game_text2sql` 응답 profile과 집계 결과를 검증한다.
## 롤백
새 프로파일을 삭제하지 않는다. MCP에서 오류가 나거나 SQL 품질이 허용 기준을 충족하지 않으면 `/etc/smilegate/backoffice.env``BACKOFFICE_SELECT_AI_PROFILE``SGMP_POC_HAIKU45`로 되돌린 뒤 서비스를 재기동한다.
## 검증 기준
- 새 프로파일 provider=`oci`, model=`openai.gpt-5.4-mini`, credential=`SGMP_POC_OCI_DEFAULT_CRED`, region=`us-chicago-1`
- 한글 질의는 SQLcl UTF-8 base64 복원식으로 전달한다.
- `DBMS_CLOUD_AI.GENERATE(..., 'showsql')`이 읽기 전용 `SELECT` 또는 `WITH` SQL을 생성한다.
- 해당 SQL을 읽기 전용 트랜잭션에서 실행해 결과를 반환한다.
- 운영 MCP 응답의 `profile``SGMP_POC_OCI_GPT54MINI`이다.

View File

@@ -1,125 +0,0 @@
# HMM 포털 URL 토큰 제거와 HttpOnly 쿠키 인증 설계 (#709)
> 상태: 구현·배포·검증 완료
> 대상: `https://hmm.cloud-handson.com`
> 브랜치: `hmm-backoffice`
## 문제
현재 Streamlit 로그인 유지 기능은 서명된 토큰을 `poc4_remember` query parameter에 저장한다.
토큰이 암호화된 비밀번호는 아니더라도 유효 기간 동안 인증 수단으로 작동하므로 다음 위치에 남을 수
있다.
- 브라우저 주소와 방문 기록
- Nginx·상위 프록시 access log
- 사용자가 복사한 링크와 화면 캡처
- 외부 링크 이동 시 Referer
인증 수단은 URL에 포함하지 않는다. 기존 query-token 코드를 삭제하고 기존 서명 secret을
회전해 과거 URL을 즉시 무효화한다.
## 목표
- 로그인은 `POST /auth/login`으로만 처리한다.
- 인증 상태는 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/` 쿠키에만 둔다.
- Nginx가 모든 Streamlit HTTP·WebSocket 요청 전에 쿠키를 검증한다.
- Streamlit은 외부 요청 헤더가 아니라 Nginx가 덮어쓴 내부 사용자 헤더만 사용한다.
- 로그아웃은 쿠키를 만료시키고 로그인 화면으로 돌아간다.
- 로그인·로그아웃 이후 주소에 토큰이나 자격 증명이 남지 않는다.
## 구성
```text
Browser
├─ GET /auth/login ────────────────┐
├─ POST /auth/login (ID/password) │
└─ Cookie: __Host-HMM_PORTAL_SESSION
Nginx :443
├─ /auth/* ───────────────► auth_gateway.py :8621
└─ /* + auth_request ──────► /auth/check
├─ 204 + X-Auth-User ─► Streamlit :8622
└─ 401 ───────────────► /auth/login
```
인증 서비스와 Streamlit은 모두 `127.0.0.1`에만 바인딩한다. 외부에서 인증 사용자 헤더를
보내더라도 Nginx가 `auth_request` 결과로 값을 덮어쓴다.
## 쿠키
| 항목 | 값 |
|---|---|
| 이름 | `__Host-HMM_PORTAL_SESSION` |
| 속성 | `Secure; HttpOnly; SameSite=Lax; Path=/` |
| 기본 로그인 | 브라우저 세션 쿠키, 서버 토큰 만료 12시간 |
| 로그인 유지 | `Max-Age=604800`, 서버 토큰 만료 7일 |
| 형식 | version, user, issued-at, expiry, nonce를 담은 base64url payload + HMAC-SHA256 |
| 서명키 | `POC4_LOGIN_COOKIE_SECRET`, Git·로그 미기록 |
`__Host-` 접두사는 `Secure`, `Path=/`, Domain 미지정 조건을 강제해 하위 도메인의 쿠키
주입 범위를 줄인다.
## 로그인 보호
- PBKDF2 비밀번호 해시는 기존 `POC4_LOGIN_PASSWORD_PBKDF2`를 사용한다.
- 로그인 GET에서 10분 유효한 일회용 CSRF 쿠키와 hidden 값을 발급한다.
- 로그인 POST는 CSRF 두 값을 상수 시간 비교한 뒤 자격 증명을 확인한다.
- 오류 메시지는 사용자 존재 여부와 비밀번호 실패를 구분하지 않는다.
- 요청 body와 필드 길이를 제한한다.
- 실패 횟수는 IP별 짧은 시간 창에서 제한한다.
- 인증 응답에는 `Cache-Control: no-store`와 보안 헤더를 설정한다.
- 서비스 로그에는 query string, 쿠키, 비밀번호, 토큰을 기록하지 않는다.
## Streamlit 변경
- `poc4_remember` 상수, 생성, 복원, query 정리 코드를 삭제한다.
- Streamlit 내부 로그인 유지 로직을 삭제한다.
- `st.context.headers["X-HMM-Authenticated-User"]`가 설정된 경우에만 포털 세션을 활성화한다.
- 기대 사용자와 프록시 사용자 값은 상수 시간 비교한다.
- 로그아웃 UI는 `/auth/logout`으로 이동해 쿠키를 만료시킨다.
- 신뢰 헤더가 없으면 자격 증명 폼 대신 인증 게이트웨이 설정 오류만 표시한다.
## 배포
1. 인증 서비스 소스와 systemd unit을 `/opt/hmm-poc4`에 배포한다.
2.`POC4_LOGIN_COOKIE_SECRET`을 root 소유 환경 파일에 추가하고 기존
`POC4_LOGIN_REMEMBER_SECRET`은 제거한다.
3. 인증 서비스를 `127.0.0.1:8621`에서 시작한다.
4. Nginx 설정에 `/auth/*`, 내부 `/auth/check`, `auth_request`를 적용한다.
5. Streamlit 소스를 배포하고 서비스를 재시작한다.
6. `nginx -t`, 서비스 상태, 로그인·쿠키·WebSocket·로그아웃을 검증한다.
## 완료 검증
- 기존 `?poc4_remember=<old-token>` 요청이 인증되지 않고 로그인 화면으로 이동한다.
- 로그인 POST 응답의 `Location``/`이고 URL에 토큰이 없다.
- 세션 쿠키에 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`가 모두 있다.
- 조작·만료 쿠키는 `/auth/check`에서 401이다.
- 인증 쿠키가 없으면 Streamlit asset·WebSocket을 포함한 보호 경로를 사용할 수 없다.
- 로그인 후 포털 주요 탭, MCP 설정, 사용자 전환이 정상 동작한다.
- 로그아웃 후 쿠키가 만료되고 보호 경로가 다시 로그인 화면으로 이동한다.
## 롤백
변경 전 Nginx 설정, Streamlit 소스, 환경 파일을 타임스탬프 백업한다. 장애 시 이 세 파일을
복구하고 인증 서비스를 중지한다. 롤백을 해도 query-token 구현은 재활성화하지 않으며, 임시로
포털 접근을 차단하는 쪽을 우선한다.
## 배포 검증 결과
2026-07-23 운영 배포에서 다음을 확인했다.
- `hmm-portal-auth.service`, `poc4-streamlit.service`, `nginx` 모두 `active`
- 기존 query-token 서명키 제거·회전, 환경 백업의 이전 서명키도 제거
- `/` 미인증 요청: `/auth/login`으로 이동
- `/?poc4_remember=retired-token`: 인증되지 않고 `/auth/login`으로 이동하며 query 제거
- 로그인 페이지: URL token 없음, CSRF cookie는 `Secure; HttpOnly; SameSite=Strict`
- 포털 session cookie: `Secure; HttpOnly; SameSite=Lax; Path=/`
- 브라우저 `document.cookie`에서 session cookie를 읽을 수 없음
- 인증 후 URL: `https://hmm.cloud-handson.com/`, query 없음
- 아키텍처·시나리오·감사로그·보안관리 탭 및 MCP endpoint 설정 표시 정상
- 로그아웃 후 session cookie 제거와 로그인 화면 복귀 확인
- Python 단위·HTTP 통합 테스트 21건 통과, 선택적 Streamlit runtime 테스트 1건 skip
- 브라우저 page error 0건, console error 0건
상세 증거는 `docs/reports/2026-07-23-hmm-portal-cookie-auth-verification.md`에 기록한다.

View File

@@ -0,0 +1,27 @@
# #710 POC4 URL 로그인 토큰 제거
## 프로젝트 개요
Smilegate DATA & AI PoC의 POC4 Streamlit 콘솔은 게임 데이터 MCP와 Select AI Text2SQL 데모를 제공한다. 포털 로그인은 콘솔 접근을 보호한다.
## 문제
로그인 유지용 서명 토큰이 `poc4_remember` query parameter로 URL에 포함됐다. URL은 브라우저 기록, 프록시 로그, 공유 링크, Referrer에 남을 수 있으므로 인증 정보를 전달하는 경로로 사용하면 안 된다.
## 조치 설계
1. Streamlit 코드에서 URL 토큰 생성·검증·삭제를 모두 제거한다.
2. 로그인 상태는 현재 Streamlit 브라우저 세션에서만 유지한다. 서버가 `HttpOnly`, `Secure`, `SameSite` cookie를 발급하는 전용 인증 경로가 마련되기 전에는 영구 로그인 기능을 제공하지 않는다.
3. `POC4_LOGIN_REMEMBER_SECRET`을 교체해 기존 서명 링크를 무효화한다.
4. Caddy가 기존 `poc4_remember` query 요청을 애플리케이션으로 전달하지 않고 `https://smilegate.cloud-handson.com/`으로 303 redirect한다.
## 검증 기준
- `mcp_discovery_ui.py``poc4_remember` 또는 `st.query_params` 로그인 토큰 코드가 없다.
- 기존 query URL 요청은 query가 없는 루트 URL로 303 응답한다.
- `smilegate-poc4-console.service`가 정상 기동한다.
- 토큰, password hash, signing key는 Git·Redmine·명령 출력에 기록하지 않는다.
## 후속 개선
영구 로그인 요구가 다시 생기면 POC4 자체가 아닌 서버 인증 endpoint가 `HttpOnly; Secure; SameSite=Lax` cookie를 발급하고, Streamlit은 요청 cookie의 서버 검증 결과만 읽는 구조로 구현한다.

View File

@@ -1,78 +0,0 @@
# HMM 백오피스 MCP 사용자 Bearer 인증 설계 (#712)
> 상태: 구현·배포·검증 완료
> 대상: `https://hmm-backoffice.cloud-handson.com/mcp`
> 브랜치: `hmm-backoffice`
## 목적
HMM 백오피스에서 발급한 `vpd_live_*` 사용자 토큰을 MCP의 실제 인증 수단으로 사용한다.
현재 별도 호스트의 `https://hmm-mcp.cloud-handson.com/mcp`는 서버 공용 게이트웨이 토큰만
허용하므로 사용자 토큰을 보내면 Nginx에서 HTTP 401을 반환한다. 사용자별 HMM 데모는
백오피스 MCP 주소를 사용해야 한다.
## 확인된 현행 결함
- 백오피스의 `/mcp``initialize`, `tools/list`, `tools/call`을 제공한다.
- Controller가 `Authorization: Bearer` 값을 추출하지만 Service의
`ignoredAuthorization` 인자로 전달해 검증하지 않는다.
- Tool 실행은 토큰 사용자 컨텍스트를 설정하지 않고 백오피스 JDBC 계정으로 바로
`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다.
- 따라서 주소를 올바르게 사용해도 토큰이 인증과 행 접근 문맥에 연결되지 않는다.
## 변경 설계
```text
Private Agent Factory
→ POST https://hmm-backoffice.cloud-handson.com/mcp
→ Authorization: Bearer <vpd_live 사용자 토큰>
→ HMM_ACCESS_BEARER_TOKENS SHA-256/만료/회수/재직 검증
→ 동일 JDBC connection에서 HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER
→ DBMS_CLOUD_AI_AGENT.RUN_TOOL
→ finally HMM_ACCESS_CTX_PKG.CLEAR_USER
```
1. 모든 MCP method는 활성 사용자 Bearer를 요구한다. 누락·오류·만료·회수 토큰은 HTTP 401로
fail-closed한다.
2. 원문 토큰은 로그, 응답, DB, Git에 기록하지 않는다. DB에는 기존 SHA-256 해시만 사용한다.
3. `tools/call`은 토큰 검증과 Tool 실행을 같은 요청에서 수행한다.
4. Tool 실행 connection에는 컨텍스트를 설정하고 성공·실패와 무관하게 `finally`에서 지운다.
5. `initialize``tools/list`도 토큰을 검증해 discovery만으로 인증을 우회할 수 없게 한다.
## Agent Factory 설정
| 항목 | 값 |
|---|---|
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
| Authentication mode | `Bearer Token` |
| Bearer token | 백오피스에서 발급한 토큰 원문만 입력 (`Bearer ` 접두어 제외) |
별도 서버 `https://hmm-mcp.cloud-handson.com/mcp`에는 사용자 토큰을 사용하지 않는다. 그 주소는
운영 `HMM_MCP_BEARER_TOKEN`을 사용하는 호환 게이트웨이다.
## 완료 기준
- 활성 사용자 토큰으로 `initialize`, `tools/list`, 세 Tool 호출이 성공한다.
- 누락·무효·회수 토큰은 HTTP 401이다.
- Tool 호출 전후 DB context 설정·정리가 자동 테스트로 검증된다.
- 운영 배포 후 외부 HTTPS에서 discovery와 대표 Tool 호출을 검증한다.
- 토큰 원문이나 해시는 테스트 출력과 문서에 남지 않는다.
## 배포 검증 결과
2026-07-23 운영 배포에서 다음을 확인했다.
- 자동 테스트 107건 통과
- 운영 JAR과 검증 빌드 SHA-256 일치
- 무토큰 `initialize`, `tools/list`: HTTP 401
- 활성 E1002 임시 사용자 토큰:
- `initialize`: HTTP 200
- `tools/list`: HTTP 200
- `resolve_hr_term`: HTTP 200, 정상 MCP result
- 발견 도구: `resolve_hr_term`, `search_hr_data`, `search_hr_policy`
- 같은 토큰을 회수한 직후 `tools/list`: HTTP 401
- 검증 중 발급한 임시 토큰과 이전 실패 시 남은 임시 토큰을 모두 회수
- 백오피스 시스템 설정과 MCP 서비스 화면의 Agent Factory 주소를
`https://hmm-backoffice.cloud-handson.com/mcp`로 변경
상세 증거는 `docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md`에 기록한다.

View File

@@ -0,0 +1,78 @@
# 설계서: 환경변수 기반 공통 데이터 카탈로그
## 프로젝트 개요
이 백오피스는 Oracle Database의 권한, 메타데이터, Select AI와 정형 데이터 조회를
운영하기 위한 공통 관리 화면이다. 현재 일부 화면은 특정 스키마와 업무 테이블 목록을
코드에 고정하고 있어, 다른 프로젝트에 재사용하려면 Java와 MyBatis를 함께 수정해야 한다.
## 목표
1. DB 접속은 기존 `BACKOFFICE_*_DB_*` 환경변수 체계를 유지한다.
2. 메타데이터와 정형 데이터 조회 대상은 `BACKOFFICE_CATALOG_OWNER`
`BACKOFFICE_CATALOG_OBJECTS`에서 선언한다.
3. 테이블과 뷰를 공통 `DataCatalogObject` 인터페이스로 표현한다.
4. 서비스와 MyBatis는 검증된 카탈로그 객체에서 전달받은 owner, object name, object type만
사용한다. HTTP 요청값을 SQL 식별자로 쓰지 않는다.
5. 카탈로그 환경변수가 비어 있거나 잘못되면 기동 시 실패한다. 다른 고객의 객체를 기본값으로
참조하지 않는다.
## 설정 계약
```bash
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
export BACKOFFICE_CATALOG_OBJECTS='[
{"key":"sales","tableName":"SALES_TXN","objectType":"TABLE",
"businessName":"판매 거래","description":"판매 거래 정보"},
{"key":"daily-sales","tableName":"VW_DAILY_SALES","objectType":"VIEW",
"businessName":"일별 판매","description":"일별 판매 집계 뷰"}
]'
```
- `key`: 화면 URL과 선택값에 사용하는 영문 키. 소문자, 숫자, `-`만 허용한다.
- `tableName`: Oracle 단순 식별자. 대문자, 숫자, `_`, `$`, `#`만 허용한다.
- `objectType`: `TABLE` 또는 `VIEW`.
- `businessName`, `description`: 화면 표시용 텍스트.
잘못된 JSON, 중복 key/name, 빈 목록, 허용되지 않은 식별자는 기동 시 명확히 실패한다.
## 구조
```text
환경변수
→ CatalogProperties
→ DataCatalog
→ StructuredDataService / SchemaMetadataService
→ MyBatis Mapper
→ Oracle dictionary / 허용 객체
```
`DataCatalog`은 허용 객체를 해석하는 단일 진입점이다. 미리보기 SQL은 객체 이름을
카탈로그에서만 받아 조합하며, 목록 밖 이름은 SQL에 들어갈 수 없다.
## 보안 SQL 번들
보안 SQL 화면은 `BACKOFFICE_SECURITY_SQL_SCRIPTS` JSON 배열에 선언한 번들만 표시한다.
각 항목은 `scriptId`, `category`, `fileName`, `title`, `description`을 가진다.
`fileName`은 패키지의 `sql/adb/` 하위 상대 경로만 허용하며, 요청값으로 경로를 만들지 않는다.
기존 고객 전용 SQL은 `sql/adb/legacy/<customer>/`에 보존하고, 다른 환경에는 해당 목록을
선언하지 않는다.
## MyBatis 처리
- table/view comment와 column comment 조회는 `owner`, `objectName`을 바인드한다.
- annotation 조회는 Oracle dictionary 제약에 맞춰 `objectName`, `objectType`을 함께
바인드한다.
- 주석 DDL은 `COMMENT ON TABLE` 문법으로 테이블 또는 뷰에 적용한다.
- annotation DDL은 `TABLE`에만 허용한다. 뷰는 comment 편집만 제공한다.
## 완료 기준
- 환경변수로 테이블과 뷰를 섞은 카탈로그를 선언할 수 있다.
- metadata와 preview가 선언된 owner/object만 조회한다.
- 뷰의 comment/column comment는 조회·수정 가능하고, annotation 편집은 차단된다.
- 설정 파싱과 허용 목록 검증을 자동 테스트한다.
## 비범위
- Select AI profile 내부 object list를 자동으로 생성·변경하지 않는다.

View File

@@ -1,255 +0,0 @@
# HMM 백오피스 환경 기반 공통 카탈로그 전환
- Redmine: #723
- 기준 설계: Smilegate #722 `docs/design/722-configurable-data-catalog/README.md`
- 기준 커밋: `53342e7` (`smilegate`)
- 적용 브랜치: `hmm-backoffice`
- 운영 주소: `https://hmm-backoffice.cloud-handson.com`
## 1. 배경
현재 HMM 백오피스는 정형 데이터 목록을 별도 JSON 파일로 분리했지만 제품명, MCP 도구,
마스킹 대상, 보안 SQL 목록 등은 Java와 Thymeleaf에 남아 있다. 같은 백오피스 틀을 다른
고객사에 재사용하려면 소스를 수정하고 다시 빌드해야 한다.
Smilegate #722는 데이터 객체, 제품 표시명, 마스킹 정책, MCP/Select AI, 보안 SQL 목록을
환경 설정으로 옮겼다. HMM에는 이 구조를 적용하되 다음 현행 기능을 보존해야 한다.
- HMM 직원 Bearer token을 해시로 검증한다.
- MCP discovery와 tool call 모두 인증한다.
- AI Agent Tool 실행과 같은 DB 세션에서 `HMM_ACCESS_CTX`를 설정하고 반드시 해제한다.
- 용어 변환, 정형 HR 조회, 규정 PDF 검색의 세 MCP 도구를 계속 제공한다.
- 팀장과 팀원의 VPD 행 접근 규칙 및 마스킹 관리 기능을 유지한다.
## 2. 목표
1. 승인 데이터 객체와 객체 유형을 환경 JSON으로 설정한다.
2. TABLE과 VIEW를 모두 읽기 전용 미리보기 대상으로 지원한다.
3. DB comment는 TABLE/VIEW에 지원하고 Oracle annotation 변경은 TABLE에만 허용한다.
4. Data Redaction 관리 대상과 정책명을 별도 환경 JSON으로 설정한다.
5. 제품명, 페이지 타이틀, 데이터 명칭, MCP 공개 주소와 도구 계약을 환경 설정으로 옮긴다.
6. Smilegate #722의 단일 Select AI 도구 설정과 HMM의 복수 Agent Tool 설정을 모두 수용한다.
7. 번들 보안 SQL 화면의 노출 목록을 환경 allowlist로 제한한다.
8. 잘못된 설정은 애플리케이션 시작 시 거부한다.
## 3. 제외 범위
- HMM 사용자·그룹·역할·권한 테이블 구조 변경
- HMM 지식 문서 적재 구조 변경
- 기존 VPD 함수와 Data Redaction 정책 DDL 재작성
- Select AI 프로파일의 object list 자동 변경
- 운영 Bearer token 원문을 설정이나 Git에 저장
## 4. 설정 계약
### 4.1 데이터 카탈로그
```text
BACKOFFICE_CATALOG_OWNER
BACKOFFICE_CATALOG_OBJECTS
```
`BACKOFFICE_CATALOG_OBJECTS`는 아래 필드를 갖는 JSON 배열이다.
```json
[
{
"key": "employees",
"tableName": "HMM_HR_EMPLOYEES",
"objectType": "TABLE",
"businessName": "직원 원장",
"description": "직원·매니저·소속팀 정보",
"previewColumns": ["EMPLOYEE_ID", "EMPLOYEE_CODE", "EMPLOYEE_NAME"]
}
]
```
검증 규칙:
- owner와 tableName은 Oracle simple identifier만 허용한다.
- key는 소문자 영문으로 시작하고 소문자, 숫자, 하이픈만 허용한다.
- objectType은 `TABLE` 또는 `VIEW`만 허용한다.
- key와 tableName은 각각 중복될 수 없다.
- businessName과 description은 비어 있을 수 없다.
- previewColumns는 선택값이다. 지정하면 검증된 컬럼만 조회하며 VECTOR/BLOB 등 관리자
미리보기에 부적합한 컬럼을 제외할 수 있다.
- 빈 목록이나 잘못된 JSON이면 애플리케이션 시작을 실패시킨다.
정형 데이터 미리보기는 카탈로그에서 선택한 객체만 SQL 식별자로 사용하고 최대 50건만
반환한다. 요청 파라미터를 SQL 객체명으로 직접 사용하지 않는다.
### 4.2 제품 표시
```text
BACKOFFICE_PRODUCT_NAME
BACKOFFICE_PRODUCT_TITLE
BACKOFFICE_PRODUCT_DATA_LABEL
```
공통 레이아웃, 데이터 화면, 메타데이터 화면, MCP 화면은 이 값을 사용한다. HMM 운영값은
`HMM HR Access Console``HMM HR 데이터`이다.
### 4.3 마스킹 정책
```text
BACKOFFICE_MASKING_POLICIES
```
```json
[
{
"objectName": "HMM_HR_EMPLOYEES",
"policyName": "HMM_EMPLOYEE_PII_REDACT"
}
]
```
객체명과 정책명은 Oracle simple identifier로 검증하고, objectName과 policyName 중복을
각각 거부한다. 빈 값이면 관리 가능한 Data Redaction 정책이 없는 fail-closed 상태로
동작한다.
### 4.4 MCP
Smilegate #722의 단일 Select AI 도구 환경변수를 호환한다.
```text
BACKOFFICE_MCP_PUBLIC_URL
BACKOFFICE_MCP_SERVER_NAME
BACKOFFICE_MCP_TOOL_NAME
BACKOFFICE_MCP_TOOL_LABEL
BACKOFFICE_MCP_TOOL_DESCRIPTION
BACKOFFICE_MCP_PROMPT_DESCRIPTION
```
HMM처럼 여러 DBMS Cloud AI Agent Tool을 노출하는 배포는 다음 JSON을 사용한다.
```text
BACKOFFICE_MCP_TOOLS
```
```json
[
{
"name": "resolve_hr_term",
"label": "HMM HR 용어 표준화",
"description": "휴가·근태 표현을 표준 용어와 코드로 변환합니다.",
"argumentName": "term",
"argumentDescription": "확인할 휴가·근태 용어, 동의어 또는 코드입니다.",
"executionType": "AGENT_TOOL",
"targetName": "HMM_HR_TERM_RESOLVER",
"targetParameterName": "P_TERM"
}
]
```
`executionType``AGENT_TOOL` 또는 `SELECT_AI`만 허용한다. `BACKOFFICE_MCP_TOOLS`
비어 있으면 #722의 단일 `SELECT_AI` 도구 설정을 사용한다. HMM 운영은 세 개의
`AGENT_TOOL` 정의를 환경에 둔다.
MCP 보안 경계:
1. initialize, tools/list, tools/call 모두 유효한 HMM 사용자 Bearer token이 필요하다.
2. token 원문은 로그, 응답, DB에 저장하지 않는다.
3. AGENT_TOOL 실행 전 같은 JDBC 연결에서
`ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 실행한다.
4. 성공·실패와 관계없이 같은 연결에서 `CLEAR_USER`를 실행한다.
5. MCP JSON-RPC 오류에 DB password, SQL 전체 stack trace를 포함하지 않는다.
### 4.5 Select AI
```text
BACKOFFICE_SELECT_AI_DB_URL
BACKOFFICE_SELECT_AI_DB_USERNAME
BACKOFFICE_SELECT_AI_DB_PASSWORD
BACKOFFICE_SELECT_AI_PROFILE
```
단일 Select AI 도구는 프로파일 소유 계정으로 SHOWSQL을 생성한 후 다음 검증을 모두
통과한 SQL만 읽기 전용 트랜잭션에서 실행한다.
- 첫 문장은 SELECT 또는 WITH
- 세미콜론을 이용한 다중 문장 금지
- DDL, DML, transaction, lock, PL/SQL, DBMS/UTL/SYS 호출 금지
- 최대 100건, query timeout 30초
- 실행 후 항상 rollback
HMM 운영 MCP는 현재 세 개의 Agent Tool을 사용하므로 Select AI 접속은 선택 설정이다.
다른 고객사는 단일 Select AI 도구만 설정할 수 있다.
### 4.6 보안 SQL
```text
BACKOFFICE_SECURITY_SQL_SCRIPTS
```
번들된 `database/adb` 경로 안의 파일만 허용하며 요청값을 resource path로 사용하지 않는다.
HMM 운영에서는 `72_hmm_leave_team_vpd.sql` 등 HMM 관련 SQL만 노출한다.
## 5. 코드 구조
| 구성 | 역할 |
|---|---|
| `DataCatalog` | 승인 객체 조회 인터페이스 |
| `EnvironmentDataCatalog` | 환경 JSON 파싱, 정규화, fail-fast 검증 |
| `MaskingPolicyCatalog` | 관리 가능한 Data Redaction 대상 조회 |
| `EnvironmentMaskingPolicyCatalog` | 마스킹 환경 JSON 파싱과 검증 |
| `ProductProperties` | 제품명, 타이틀, 데이터 표시명 |
| `McpProperties` | 공개 URL, 서버명, 단일 Select AI 호환 설정, 복수 도구 JSON |
| `EnvironmentMcpToolCatalog` | MCP 도구 계약 파싱과 allowlist |
| `McpSseService` | JSON-RPC와 인증 경계, 설정 기반 도구 dispatch |
| `SelectAiService` | SHOWSQL 생성, 읽기 전용 검증 및 제한 실행 |
| `SecuritySqlScriptProperties` | 보안 SQL allowlist |
고객사별 이름과 객체는 `application.yml`의 환경 매핑과 운영 env에만 둔다. HMM의 DB
매퍼와 VPD 함수는 HMM 업무 모델 자체이므로 이번 공통화 대상이 아니다.
## 6. TABLE/VIEW 메타데이터
- `all_tab_comments`, `all_tab_columns`, `all_col_comments`는 TABLE/VIEW 공통 조회에 쓴다.
- `COMMENT ON TABLE``COMMENT ON COLUMN`은 승인 객체에만 실행한다.
- Oracle annotation 조회·변경은 TABLE에만 허용한다.
- VIEW를 선택한 경우 annotation 입력 UI를 숨기고 서버도 요청을 거부한다.
- 화면에는 카탈로그의 objectType과 owner를 표시한다.
## 7. 운영 이행
1. 기존 `/etc/vpd-backoffice.env`를 백업한다.
2. 기존 DB 비밀번호와 remember-me secret은 변경하지 않는다.
3. 데이터 카탈로그 6개, 마스킹 정책 4개, MCP 도구 3개, HMM 보안 SQL 목록을 추가한다.
4. `BACKOFFICE_HMM_MCP_PUBLIC_URL`
`BACKOFFICE_MCP_PUBLIC_URL`로 이전하되 한 릴리스 동안 fallback을 지원한다.
5. JAR 교체 후 systemd를 재시작한다.
6. 10초 간격으로 health, 로그인, 정형 데이터, 메타데이터, 마스킹, MCP를 확인한다.
7. 실패 시 기존 JAR와 env 백업으로 복구한다.
## 8. 검증
자동 검증:
- 카탈로그 정상/빈 값/잘못된 식별자/중복 key/중복 object 테스트
- TABLE/VIEW 미리보기 SQL과 row limit 테스트
- VIEW annotation 변경 거부 테스트
- 마스킹 정책 정상/빈 값/중복 테스트
- MCP 3개 도구 discovery, 정확한 argument/target dispatch, 인증 거부 테스트
- Select AI 안전 SQL 검증 테스트
- 보안 SQL resource allowlist 테스트
- 전체 `mvn test`
운영 검증:
- 관리자 로그인과 지속 로그인
- 전체 메뉴 HTTP 200 및 HMM 데이터 표시
- 정형 데이터 6개 객체 조회
- 메타데이터 6개 객체 조회
- 마스킹 정책 4개 상태 조회
- MCP 무토큰 401, 잘못된 token 401
- 임시 사용자 token으로 tools/list와 세 도구 call
- 서비스 로그에 startup 오류와 비밀정보 출력이 없는지 확인
## 9. 완료 조건
- HMM 백오피스의 고객사별 카탈로그와 표시 설정이 운영 env로 이전되어 있다.
- 기존 HMM 토큰 인증과 VPD 컨텍스트 적용이 회귀하지 않는다.
- Maven 테스트와 운영 전수 검증이 통과한다.
- Gitea `hmm-backoffice`#723 커밋이 push되어 있다.
- Redmine #723이 Planner부터 Documenter까지 근거와 함께 완료되어 있다.

View File

@@ -0,0 +1,74 @@
# 723. SGMP QA Vector Retrieval
## Goal
Store curated question, answer SQL, answer text, and their combined retrieval
document in `SGMP_POC`. Retrieve the top-K closest examples for a new question
and pass the returned context to the Text2SQL prompt in a later application
integration.
## Security boundary
- IAM identity: `sgmp-qa-vector-api`
- IAM group: `sgmp-vector-embed-group`
- IAM policy: only `use generative-ai-text-embedding in tenancy`
- Database credential: `SGMP_POC_QA_VECTOR_CRED`, created from that dedicated
API signing key only. It does not reuse `SGMP_POC_OCI_DEFAULT_CRED`.
- Network: HTTPS only to OCI GenAI Chicago EmbedText endpoint on port 443. The
`SGMP_POC` ACE is provisioned once by an ADB `ADMIN` connection because an
application schema cannot administer network ACLs.
- The private key is read from `VECTOR_OCI_API_KEY_FILE`; it is never committed,
displayed, or persisted outside the encrypted database credential.
## Embedding contract
- Model: `cohere.embed-v4.0`
- Dimension: `1536` FLOAT32
- The current ADB `DBMS_VECTOR` OCI adapter does not forward Cohere Embed 4's
`input_type` field; both paths therefore use the provider's compatible
default request shape. The model and 1536-dimension vector contract remain
fixed. Once the adapter exposes Embed 4 `input_type`, switch stored examples
to `search_document` and incoming questions to `search_query`.
- `p_top_k` default: `3` (accepted range `1..20`)
Oracle recommends distinct document/query input types for Cohere Embed 4 RAG
flows and its default output size is 1536. See [Cohere Embed 4](https://docs.oracle.com/en-us/iaas/Content/generative-ai/cohere-embed-4.htm).
## Database API
```sql
-- Stores question + answer SQL + optional answer and returns EXAMPLE_ID.
SELECT sg_qa_vector_store(:question, :answer_sql, :answer_text) FROM dual;
-- Returns EXAMPLE_ID, QUESTION, ANSWER_SQL, ANSWER_TEXT, MODEL and distance.
DECLARE
results SYS_REFCURSOR;
BEGIN
results := sg_qa_vector_search(:question); -- default top 3
END;
/
-- Ready-to-insert textual context for a prompt.
SELECT sg_qa_vector_context(:question, 3) FROM dual;
```
`SG_QA_VECTOR_STORE`는 SQL `SELECT` 표현식으로 호출되는 저장 함수이므로,
함수 내부의 INSERT는 자율 트랜잭션으로 수행하고 성공 시 commit, 실패 시 rollback
한다. 이 처리가 없으면 Oracle은 `ORA-14551`로 DML을 거절한다.
## Apply
```bash
export SGMP_POC_DB_PASSWORD='...'
export SGMP_POC_WALLET_DIR='/path/to/Wallet_SGMPAIPOC'
export VECTOR_OCI_USER_OCID='...'
export VECTOR_OCI_TENANCY_OCID='...'
export VECTOR_OCI_COMPARTMENT_OCID='...'
export VECTOR_OCI_API_KEY_FILE='/secure/path/sgmp_qa_vector_api_key.pem'
export VECTOR_OCI_API_KEY_FINGERPRINT='...'
./scripts/setup-sgmp-qa-vector.sh
```
For the initial small QA corpus, exact cosine search is deliberate: it makes
results immediately verifiable. Add a vector index only after the corpus size
and recall/latency target are measured.

View File

@@ -0,0 +1,52 @@
# Redmine #726 · Select AI SHOWPROMPT 진단 MCP
## 목표
기존 Text2SQL 생성·실행 tool과 분리된 읽기 전용 SHOWPROMPT 진단 tool을
MCP `tools/list`에 추가한다. 일반 질문 처리 Agent는 포털 allowlist를 통해
기존 tool만 사용하고, FAIL 개선 제안 버튼만 진단 tool을 직접 호출한다.
## 외부 설정
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME`
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL`
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION`
기본값은 제품 중립적인 `oracle.select_ai.data_showprompt`와 업무 데이터
표현을 사용한다. 스마일게이트 운영값은 외부 env에서 고객 전용 이름으로
설정한다.
## 동작
1. HTTP Bearer Token을 기존 업무 사용자 토큰으로 검증한다.
2. prompt를 기존 4,000자 제한으로 검증한다.
3. schema-owned Select AI 연결에서
`DBMS_CLOUD_AI.GENERATE(prompt, profile, 'showprompt')`를 호출한다.
4. SQL을 실행하지 않고 다음 JSON을 반환한다.
- `status=SHOWPROMPT`
- `profile`
- `selectAiPrompt`
## 변경 함수
| 파일/함수 | 변경 |
|---|---|
| `McpProperties` | SHOWPROMPT tool 이름·label·description 외부 설정 |
| `SelectAiService.generatePrompt` | 인증·prompt 검증 후 SHOWPROMPT 반환 |
| `SelectAiService.generate` | `showsql/showprompt` action을 bind하는 공통 생성 함수 |
| `McpSseService.toolsListResult` | Text2SQL과 SHOWPROMPT 두 tool 등록 |
| `McpSseService.toolsCallResult` | exact tool name에 따라 query/diagnostic 분기 |
## 안전 조건
- SHOWPROMPT tool은 생성 SQL을 실행하지 않는다.
- 임의 action 인자를 사용자에게 받지 않는다.
- 응답에 Bearer Token이나 DB 연결 정보를 포함하지 않는다.
- 기존 Text2SQL tool 이름과 계약은 유지한다.
## 테스트
- tools/list에 두 tool과 각 prompt schema가 존재한다.
- Text2SQL 호출은 기존 generate-and-execute 경로를 유지한다.
- SHOWPROMPT 호출은 generatePrompt만 실행한다.
- 누락/비활성 Bearer Token은 기존과 동일하게 거절한다.

View File

@@ -0,0 +1,65 @@
# 731. SGMP QA Vector MCP 도구
## 목표
백오피스 MCP에 저장된 QA 벡터 예제를 조회·저장하는 두 도구를 추가한다.
- 조회 도구는 현재 질문과 유사한 예제 SQL을 Select AI 호출 전에 확인하여
few-shot 컨텍스트로 사용할 수 있게 한다.
- 저장 도구는 검토된 Select AI 결과를 다음 질의 품질 개선용 예제 SQL로 저장한다.
## MCP 계약
| 도구 | 입력 | 반환 | 용도 |
|---|---|---|---|
| `oracle.select_ai.qa_vector_search` | `question`, 선택 `topK`(기본 3) | 예제 ID, 질문, 답 SQL, 답변, cosine distance | Select AI 실행 전 few-shot 후보 확인 |
| `oracle.select_ai.qa_vector_store` | `question`, `answerSql`, 선택 `answer` | 저장된 exampleId, 모델 | 검토된 Select AI 예제 SQL 축적 |
도구 이름·표시명·설명은 모두 `BACKOFFICE_MCP_QA_VECTOR_*` 환경 변수로
바꿀 수 있다. MCP의 공통 `prompt` 인자를 재사용하지 않아 검색과 저장의
입력 의미를 명확히 분리한다.
## 연결 및 보안
1. HTTP Bearer Token은 기존 업무 사용자 토큰 검증을 통과해야 한다.
2. 벡터 DB 호출은 `BACKOFFICE_SELECT_AI_DB_*`로 만든 SGMP_POC 연결만 사용한다.
3. API 서명 키, DB 비밀번호, credential 이름은 MCP 응답·로그에 포함하지 않는다.
4. 검색은 `SG_QA_VECTOR_SEARCH` DB 함수만 호출한다. 저장은
`SG_QA_VECTOR_STORE` DB 함수만 호출한다.
5. 저장 도구는 호출자가 검토한 결과만 보내는 운영 계약이다. Select AI 실행
결과를 자동으로 저장하지 않는다.
## Select AI 연계 순서
1. Agent가 사용자 질문으로 `qa_vector_search`를 호출한다.
2. 반환된 상위 2~3개 예제의 질문·답 SQL을 Select AI 프롬프트의 few-shot
컨텍스트로 사용한다.
3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다.
4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다.
Text2SQL은 `BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED`가 true일 때 검색 결과의
상위 `BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K`개(기본 3, 최대 3)를 내부 프롬프트에
자동 보강한다. 예제는 현재 object list·게임 별칭 해석·정책을 대체하지 않으며,
보강 실패 또는 일치 예제 없음은 기존 Text2SQL 경로를 중단시키지 않는다.
고객 질문 재평가에서 FAIL이 확인되면 기준 SQL을 검토한 뒤에만 저장하고, 같은
질문을 다시 실행해 `fewShotStatus=APPLIED` 및 판정 개선 여부를 기록한다.
## Smilegate 포털 allowlist
`poc4_active_source_20260714/config/mcp_servers.json`
`smilegate_game_data_mcp` allowlist에는 다음 세 도구만 둔다.
- `oracle.select_ai.smilegate_game_text2sql`
- `oracle.select_ai.qa_vector_search`
- `oracle.select_ai.qa_vector_store`
포털은 이 목록 밖의 백오피스 MCP 도구를 발견하거나 호출하지 않는다.
## 검증
- `tools/list`에 기존 두 도구와 새 두 도구가 함께 노출된다.
- 검색의 `topK` 기본값은 3이고 범위는 1~20이다.
- 저장 도구는 question·answerSql 없이는 호출되지 않는다.
- Bearer Token 누락 시 네 도구 모두 기존과 같은 권한 거절 응답을 반환한다.
- 서비스 단위 테스트는 DB 대신 캡처 구현으로 MCP 입력·응답 계약을 검증한다.

View File

@@ -1,139 +0,0 @@
# HMM RDS PostgreSQL 가상 선사 실적 데이터
- Redmine: #729
- 적용 브랜치: `hmm-backoffice`
- 원격 원장: AWS RDS for PostgreSQL
- 대상 스키마: `hmm_demo`
- 기준월 범위: 2025-02-01 ~ 2026-07-01
## 1. 배경
HMM 데모는 Oracle ADB의 직원·조직·권한 데이터와 AWS RDS PostgreSQL의 선사 실적을
federation으로 결합한다. 사용자는 ADB에서 발급한 토큰으로 식별하고, 팀원은 자신에게
배정된 선사만, 팀장은 팀원에게 배정된 선사 전체를 조회하는 흐름을 보여준다.
이번 단계에서는 federation 연결 전에 RDS에 재현 가능한 가상 선사 실적 원장을 만든다.
실제 회사나 실제 재무 실적처럼 오인되지 않도록 모든 선사명과 수치는 데모 전용으로
생성한다.
## 2. 시스템별 책임
| 시스템 | 보관 정보 | 접근 제어 책임 |
|---|---|---|
| RDS PostgreSQL | 선사 기본정보, 선사별 월간 KPI | 전용 스키마와 조회 전용 역할 |
| Oracle ADB | 직원, 팀, 담당 선사 배정, 사용자 토큰 | VPD와 사용자 세션 context |
| Select AI/MCP | 자연어 질문, 보안 View 조회 | ADB에서 허용한 View만 사용 |
직원 사번이나 팀 정보는 RDS에 복제하지 않는다. RDS 데이터에는 선사 코드만 두고, 직원과
선사의 배정 관계는 후속 ADB 단계에서 `HMM_CARRIER_ASSIGNMENTS`로 관리한다.
## 3. 데이터 모델
### 3.1 `hmm_demo.carriers`
가상 선사 기준정보다.
| 컬럼 | 형식 | 설명 |
|---|---|---|
| `carrier_code` | `varchar(10)` | 가상 선사 식별 코드, PK |
| `carrier_name` | `varchar(120)` | 가상 선사명, UNIQUE |
| `country_code` | `char(2)` | ISO 형식의 데모 국가 코드 |
| `alliance_name` | `varchar(80)` | 가상 협력 네트워크 |
| `service_region` | `varchar(40)` | 주력 운항 권역 |
| `performance_tier` | `varchar(10)` | `CORE`, `GROWTH`, `WATCH` |
| `active` | `boolean` | 사용 여부 |
| `created_at` | `timestamptz` | 최초 생성 시각 |
### 3.2 `hmm_demo.carrier_monthly_performance`
선사별 월간 실적 fact다. 한 선사·한 기준월에 한 행만 허용한다.
| 컬럼 | 형식 | 설명 |
|---|---|---|
| `carrier_code` | `varchar(10)` | 선사 코드, FK |
| `performance_month` | `date` | 월의 첫째 날 |
| `contracted_teu` | `integer` | 계약 물동량 |
| `shipped_teu` | `integer` | 실제 운송 물동량 |
| `revenue_usd` | `numeric(16,2)` | 매출 |
| `gross_margin_usd` | `numeric(16,2)` | 매출총이익, 위험 패턴은 음수 허용 |
| `schedule_reliability_pct` | `numeric(5,2)` | 정시 운항률 |
| `vessel_utilization_pct` | `numeric(5,2)` | 선복 활용률 |
| `claim_rate_pct` | `numeric(5,2)` | 클레임 비율 |
| `yoy_growth_pct` | `numeric(7,2)` | 전년 동월 대비 성장률 |
| `risk_level` | `varchar(10)` | `GREEN`, `AMBER`, `RED` |
| `updated_at` | `timestamptz` | 적재 시각 |
PK는 `(carrier_code, performance_month)`다. 퍼센트 값은 0~100, 물동량과 매출은 0 이상을
CHECK 제약으로 보장한다. 월별 조회와 위험 선사 조회에 각각 인덱스를 둔다.
### 3.3 `hmm_demo.carrier_performance_latest_v`
가장 최근 기준월의 선사명과 KPI를 한 번에 조회하는 View다. 후속 federation 점검과
백오피스 미리보기에서 사용한다.
## 4. 가상 데이터
8개 가상 선사와 18개월 실적 144행을 만든다.
| 코드 | 가상 선사 | 의도한 데모 패턴 |
|---|---|---|
| `C001` | Bluewave Maritime | 높은 정시율과 지속 성장 |
| `C002` | Pacific Star Lines | 안정적 실적 |
| `C003` | Korea Ocean Link | 정시율과 클레임률 회복 |
| `C004` | Nordic Seaways | 계절성 변동 |
| `C005` | Orient Bridge Shipping | 점진적 물동량·마진 하락 |
| `C006` | Atlantic Merchant Line | 정시율 저하와 클레임 증가 |
| `C007` | Global Horizon Cargo | 빠른 성장 |
| `C008` | Southern Cross Marine | 지속적인 고위험 패턴 |
모든 수치는 프로파일 상수와 월 순번으로 계산한다. 난수를 사용하지 않으므로 어느 환경에서
다시 실행해도 같은 결과가 만들어진다.
## 5. 재실행과 변경 규칙
- `CREATE SCHEMA/TABLE IF NOT EXISTS`를 사용한다.
- 기준정보와 실적은 PK 기준 `ON CONFLICT DO UPDATE`로 현행화한다.
- 스크립트가 소유하지 않은 행은 삭제하지 않는다.
- 전체 실행은 하나의 transaction으로 처리한다.
- 오류가 발생하면 `ON_ERROR_STOP`과 transaction rollback으로 부분 적재를 방지한다.
- 비밀번호, endpoint, CA 파일 경로는 SQL에 넣지 않는다.
## 6. 보안
- `hmm_federation_reader``NOLOGIN` 그룹 역할로 만든다.
- 해당 역할에는 `hmm_demo``USAGE`와 테이블/View `SELECT`만 부여한다.
- ADB database link용 LOGIN 역할과 비밀번호는 federation 단계에서 별도로 생성하고
`hmm_federation_reader` 역할만 상속시킨다.
- DDL/DML 권한과 `public` 스키마 변경 권한은 federation 역할에 부여하지 않는다.
- RDS 연결은 AWS global CA bundle과 `sslmode=verify-full`로 검증한다.
## 7. 적재 순서
1. RDS TLS와 현재 계정의 CREATE 권한을 확인한다.
2. `database/source/postgres_hmm_carrier_performance.sql`을 실행한다.
3. `database/source/postgres_hmm_carrier_performance_verify.sql`을 실행한다.
4. 재실행 후에도 동일한 선사 8개와 실적 144행인지 확인한다.
5. 후속 작업에서 ADB credential과 PostgreSQL database link를 만든다.
## 8. 검증 기준
- 선사 수: `8`
- 월간 실적: `144`
- 선사별 월 수: 모두 `18`
- 기간: `2025-02-01` ~ `2026-07-01`
- 최신월 위험 분포에 `GREEN`, `AMBER`, `RED`가 모두 존재
- `C001`의 최신 매출이 최초 매출보다 증가
- `C005`, `C006`, `C008`의 최신 매출이 최초 매출보다 감소
- 존재하지 않는 선사 코드, 잘못된 퍼센트, 월 첫째 날이 아닌 날짜 입력 거부
- `hmm_federation_reader``SELECT`만 부여
- TLS 1.2 이상으로 실제 조회 성공
## 9. 제외 범위
- ADB의 `HMM_CARRIER_ASSIGNMENTS` 생성과 직원별 배정
- ADB PostgreSQL database link 생성
- ADB 보안 View와 VPD 정책
- Select AI profile 및 MCP tool 변경
- 실제 선사명이나 실제 경영 실적 적재
이 항목들은 RDS 가상 원장이 검증된 뒤 별도 단계로 진행한다.

View File

@@ -1,172 +0,0 @@
# HMM ADB-RDS PostgreSQL DB Link 녹화 절차
- Redmine: #730
- 적용 브랜치: `hmm-backoffice`
- 실행 위치: Oracle ADB Database Actions SQL Worksheet
- 실행 사용자: `ADMIN`
- 원격 DB: AWS RDS for PostgreSQL
- 원격 데이터베이스/스키마: `postgres` / `hmm_demo`
## 1. 목적
ADB SQL Worksheet에서 AWS RDS PostgreSQL을 Database Link로 연결하고, HMM 가상 선사
실적을 Oracle 로컬 View로 노출하는 전 과정을 녹화한다.
녹화 순서는 다음과 같이 고정한다.
1. 사전 점검
2. PostgreSQL 접속 Credential 생성
3. 대상 호스트 ACL 등록과 조회
4. Oracle 관리형 PostgreSQL Database Link 생성
5. 원격 테이블 직접 조회
6. Select AI에서 사용할 로컬 View와 메타데이터 생성
7. 행 수와 최신 실적 검증
## 2. 기준 자료
사내 교육자료를 실행 구문의 1차 기준으로 사용한다.
- `/Users/joungminko/devkit/fy26_ai/fy26_ai_internal_training/2회차/proxy_database/README.md`
- PostgreSQL 절차: `3.2 PostgreSQL Database 연결`
- 검증된 주의사항:
- `gateway_params``db_type`은 소문자 `postgres`
- `port`는 문자열이 아닌 숫자 `5432`
- PostgreSQL 스키마, 테이블, 컬럼 이름은 큰따옴표로 감싼다.
- Select AI에는 원격 테이블을 직접 등록하지 않고 메타데이터를 부여한 Oracle View를
등록한다.
## 3. 연결값과 객체명
| 항목 | 값 |
|---|---|
| RDS host | `database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com` |
| RDS port | `5432` |
| PostgreSQL database | `postgres` |
| PostgreSQL user | `postgres` |
| ADB Credential | `HMM_RDS_PG_CRED` |
| ADB Database Link | `HMM_RDS_PG_LINK` |
| 원격 schema | `hmm_demo` |
| 로컬 carrier View | `HMM_RDS_CARRIERS_V` |
| 로컬 월간 실적 View | `HMM_RDS_CARRIER_PERF_V` |
| 로컬 최신 실적 View | `HMM_RDS_CARRIER_LATEST_V` |
비밀번호는 SQL 파일에 저장하지 않는다. 녹화 직전 Worksheet에 붙여 넣은 Credential
구문의 `<RDS_PASSWORD>`만 실제 값으로 바꾸고, 비밀번호가 보이는 화면은 녹화에서
가리거나 일시 정지한다.
운영 전환 시에는 PostgreSQL master 사용자 대신 `hmm_federation_reader`만 상속한 전용
LOGIN 사용자를 만들어 Credential을 교체한다.
## 4. ACL의 역할
사내 교육자료의 Oracle 관리형 heterogeneous Database Link 절차에는
`DBMS_NETWORK_ACL_ADMIN` 호출이 포함되지 않는다. DB Link 연결은
`DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK`의 관리형 gateway가 수행한다.
이번 데모의 ACL 스크립트는 요청한 네트워크 통제 절차를 명시적으로 보여주고, `ADMIN`에게
RDS host의 이름 해석과 5432 포트 연결 권한이 등록되었음을 `DBA_HOST_ACES`로 확인하기
위한 단계다. 이 ACL을 AWS RDS Security Group 허용이나 관리형 gateway의 출발 IP 허용과
동일한 것으로 설명하지 않는다.
- `resolve` ACE: 포트 범위를 지정하지 않는다.
- `connect` ACE: `5432`만 지정한다.
- AWS 측에서는 RDS가 public access 가능해야 하고, Security Group이 Oracle 관리형
gateway에서 오는 접속을 허용해야 한다.
- 로컬 `global-bundle.pem``psql` 검증용이다. 관리형 DB Link 생성 구문에 업로드하거나
`directory_name`으로 지정하지 않는다.
## 5. View 설계
### 5.1 `HMM_RDS_CARRIERS_V`
`hmm_demo.carriers`의 Select AI용 기준정보 View다. PostgreSQL `boolean`
`timestamptz` 컬럼은 이번 분석 범위에서 제외해 이기종 타입 변환 변수를 줄인다.
### 5.2 `HMM_RDS_CARRIER_PERF_V`
`hmm_demo.carrier_monthly_performance`의 18개월 KPI 144행을 제공한다. 복합 Primary Key는
`CARRIER_CODE + PERFORMANCE_MONTH`이며 `CARRIER_CODE`는 carrier View를 참조한다는 관계를
View comment에 기록한다.
### 5.3 `HMM_RDS_CARRIER_LATEST_V`
원격 `hmm_demo.carrier_performance_latest_v`를 노출해 2026-07 최신월 선사 8건을 제공한다.
각 View는 원격 소문자 컬럼을 Oracle의 일반 대문자 식별자로 명시적으로 alias한다. 따라서
후속 SQL과 Select AI metadata에서는 큰따옴표 없이 안정적으로 사용할 수 있다.
### 5.4 기존 접근 그룹을 이용한 직원별 선사 배정
직원별 담당 선사는 신규 업무 테이블을 만들지 않고 기존 백오피스 접근 그룹 모델을
재사용한다.
| 기존 객체 | 선사 배정에서의 역할 |
|---|---|
| `HMM_HR_EMPLOYEES` | 직원, 팀장, 팀 관계 |
| `HMM_ACCESS_GROUPS` | `CARRIER_C001`부터 `CARRIER_C008`까지 선사 접근 그룹 |
| `HMM_ACCESS_GROUP_MEMBERS` | 직원과 담당 선사의 연결 |
| `HMM_CARRIER_ASSIGNMENTS_V` | 기존 세 테이블을 Select AI가 사용하기 쉬운 형태로 정규화 |
`HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE`는 그룹 코드에서 `CARRIER_` 접두어를 제거해
만들며, PostgreSQL 기반 `HMM_RDS_*_V.CARRIER_CODE`와 조인한다. 이 구조를 사용하면 기존
백오피스의 접근 그룹 화면에서 담당자 변경이 가능하고 별도 관리 화면이나 중복 테이블이
필요하지 않다.
`HMM_ACCESS_PERMISSION_RULES`는 행 접근 정책을 정의하는 보안 메타데이터이므로 담당 선사
원장으로 사용하지 않는다.
Select AI가 이 관계를 추론에만 의존하지 않도록 네 View의 table comment와
`CARRIER_CODE` column comment 양쪽에 상대 객체명과 조인 키를 기록한다.
- `HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE`
`HMM_RDS_CARRIERS_V.CARRIER_CODE`
- `HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE`
`HMM_RDS_CARRIER_PERF_V.CARRIER_CODE`
- `HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE`
`HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE`
## 6. 녹화용 실행 파일
| 순서 | 파일 | 화면에서 확인할 결과 |
|---|---|---|
| 0 | `73_hmm_rds_pg_00_precheck.sql` | 현재 사용자 `ADMIN`, 기존 객체 유무 |
| 1 | `74_hmm_rds_pg_01_credential.sql` | Credential 1건, 사용자명 `postgres` |
| 2 | `75_hmm_rds_pg_02_acl.sql` | `resolve`, `connect:5432` ACE |
| 3 | `76_hmm_rds_pg_03_dblink.sql` | `HMM_RDS_PG_LINK` 1건 |
| 4 | `77_hmm_rds_pg_04_remote_query.sql` | 선사 8건, 월간 실적 144건 |
| 5 | `78_hmm_rds_pg_05_views.sql` | Oracle 로컬 View 3개 |
| 6 | `79_hmm_rds_pg_06_verify.sql` | `8 / 144 / 8`, 위험도 3종 |
| 재촬영 전 | `80_hmm_rds_pg_99_cleanup.sql` | View, DB Link, Credential만 제거 |
| 7 | `81_hmm_carrier_access_groups.sql` | 선사 그룹 8건, 구성원 8건, federation 조인 8건 |
| 8 | `82_hmm_federation_relationship_comments.sql` | 양쪽 View의 명시적 조인 관계 metadata |
## 7. 성공 기준
- `ALL_CREDENTIALS`에서 `HMM_RDS_PG_CRED`가 조회된다.
- `DBA_HOST_ACES`에서 RDS host의 `resolve`, `connect`가 조회된다.
- `USER_DB_LINKS`에서 `HMM_RDS_PG_LINK`가 조회된다.
- 원격 직접 조회가 선사 8건과 실적 144건을 반환한다.
- 로컬 View 3개가 `VALID` 상태다.
- 최신 View가 8건을 반환하고 `GREEN`, `AMBER`, `RED`가 모두 존재한다.
- `CARRIER_%` 접근 그룹 8개와 직원-그룹 구성원 8건이 존재한다.
- `HMM_CARRIER_ASSIGNMENTS_V``HMM_RDS_CARRIER_LATEST_V`의 조인이 8건을 반환한다.
- Credential password가 SQL 파일, Git diff, 화면 출력에 남지 않는다.
## 8. 실패 시 판별
| 증상 | 우선 확인 |
|---|---|
| `ORA-01031` | `ADMIN`으로 실행했는지 확인 |
| Credential already exists | 재촬영 전 cleanup 실행 여부 확인 |
| Database link already exists | 재촬영 전 cleanup 실행 여부 확인 |
| `ORA-28500`, `ORA-02063`, timeout | RDS 상태, public access, Security Group, endpoint/port 확인 |
| relation does not exist | `"hmm_demo"."..."` 큰따옴표와 객체명 확인 |
| 인증 실패 | Credential의 PostgreSQL 사용자/비밀번호 확인 |
| View comment 대상 오류 | 로컬 View/컬럼은 큰따옴표 없는 대문자 식별자 사용 |
## 9. 제외 범위
- 별도 직원-선사 담당 테이블은 만들지 않고 기존 접근 그룹 객체를 재사용한다.
- 선사별 VPD 정책 적용은 후속 단계에서 수행한다.
- Select AI profile과 MCP tool 등록은 로컬 View 검증 후 후속 단계에서 수행한다.
- 실제 실행은 녹화를 진행하는 사용자가 SQL Worksheet에서 수행한다.

View File

@@ -0,0 +1,69 @@
# SGMP DB 기반 게임 범위 Resolver (#731)
## 목표
복수 게임이 포함된 데이터 질문에서 애플리케이션 코드나 에이전트 지시문에 게임명, prefix,
테이블명을 넣지 않는다. DB가 제공하는 게임 범위 뷰를 먼저 조회하고, 조회 가능으로 판정된
게임에만 기존 Few-shot NL2SQL MCP를 호출한다.
## 범위와 원칙
- 기존 `oracle.select_ai.smilegate_fewshot_nl2sql`은 예제 검색, SQL 생성, 읽기 전용 실행을
담당하는 worker로 유지한다.
-`game_scope_resolve` MCP는 SQL을 생성하거나 실행하지 않는다.
- 공통 백오피스는 환경변수로 지정된 DB view 이름과 MCP tool 이름만 안다.
- 게임명, alias, GAME_ID, GAME_PREFIX, 대상 object는 DB view의 데이터로만 결정한다.
- 지원 여부는 대상 날짜의 행 수가 아니라, 현재 승인된 조회 object가 존재하는지로 판정한다.
데이터가 0건인 날도 정상 조회 범위다.
## DB 공통 계약
고객 DB는 환경변수 `BACKOFFICE_GAME_SCOPE_VIEW`로 지정된 view를 제공한다. view는 아래
별칭(column alias)을 반환한다.
| Column | 의미 |
|---|---|
| `GAME_KEY` | 내부 게임 식별자 |
| `PROFILE_NAME` | 승인 object list를 판정한 Select AI profile |
| `DISPLAY_NAME` | 화면 표시용 정식 게임명 |
| `GAME_ALIAS` | 질문에서 찾을 게임명 또는 별칭 |
| `QUERY_ALLOWED_YN` | 승인된 조회 object 존재 여부 (`Y`/`N`) |
| `REASON_CODE` | 미지원 또는 보류 사유 코드 |
| `ALIAS_PRIORITY` | 동일/중첩 alias 정렬 우선순위 |
| `SCOPE_VERSION` | object list 변경 시 함께 갱신되는 버전 |
Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현재 Select AI profile별 승인 object list와
실제 object 존재 여부를 조합해 `QUERY_ALLOWED_YN`을 계산한다. 따라서 등록 게임이지만 현재
조회 object가 없는 게임도 `N`으로 반환된다.
## MCP와 ReAct 계약
1. 포털 ReAct는 게임 데이터 질의 전에 `game_scope_resolve(question)`를 호출한다.
2. resolver는 질문 문자열과 `GAME_ALIAS`를 정규화해 포함 관계를 찾고, 우선순위와 alias 길이로
중복을 제거한다. 동일 우선순위의 복수 게임은 `AMBIGUOUS`로 반환한다.
3. `QUERY_ALLOWED_YN=Y`인 scope에는 서명·만료된 opaque `scopeToken`과 worker tool 이름을 반환한다.
4. ReAct는 `nextAction=CALL_WORKER`인 항목만 Few-shot NL2SQL에 전달한다. `UNSUPPORTED`
`AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다.
5. Few-shot worker는 scope token을 검증하고, token에 담긴 DB scope로만 prompt를 보강한다.
## 검증
- view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다.
- resolver MCP의 결과에 구체 게임/테이블 하드코딩이 없는지 확인한다.
- STD-06에서 미지원 게임은 worker가 호출되지 않고, 지원 게임 결과에는 few-shot 예제, 생성 SQL,
실행 결과가 포함되는지 확인한다.
- 기존 단일 게임 Few-shot NL2SQL 및 미게임명 거절 guardrail 회귀를 확인한다.
## 논리 조인 메타데이터
`COMN_GAME_ALIAS_BAS`는 하나의 게임에 여러 alias 행을 갖기 때문에 `GAME_ID`가 유일키가
아니다. 따라서 공통 transaction table의 `GAME_ID`에 물리 FK를 추가하지 않는다. 대신
`78_sgmp_game_alias_logical_joins.sql`이 fact table에 `GAME_ALIAS_JOIN` annotation을 추가한다.
이 annotation은 게임명 필터에서 `EXISTS` 또는 `DISTINCT GAME_ID` alias subquery를 사용하고,
alias 원본을 직접 조인해 집계 행을 늘리지 않도록 설명한다. Prefix 전용 테이블은 가짜 FK 없이
기존 alias/prefix 소유 범위 annotation을 유지한다.
## 보류 항목
전체 게임 마스터에 미지원 게임이 없다면 DB만으로 그 이름을 게임으로 식별할 수 없다. 이 경우
고객 원천 게임 마스터를 view에 연결하는 작업이 선행되어야 하며, 모델 추측으로 보완하지 않는다.

View File

@@ -0,0 +1,42 @@
# SGMP annotation 관리 API (#734)
> 상태: Approved
> 구현: `sql/adb/76_sgmp_annotation_api.sql`
> 목적: 백오피스와 운영 스크립트가 동일한 검증 규칙으로 테이블/뷰 annotation을 추가·수정하도록 한다.
## 입력 계약
`SGMP_SET_ANNOTATION`은 스키마, 대상 종류(`TABLE` 또는 `COLUMN`), 테이블/뷰 이름, 선택적 컬럼 이름, annotation 값, 선택적 annotation 이름을 받는다. annotation 이름 기본값은 `AI_GUIDANCE`이며, 이름을 명시하면 그 이름을 사용한다.
컬럼 이름은 `COLUMN` 대상에서만 필수다. 값은 4,000자 이내이며 빈 값은 허용하지 않는다. 객체와 컬럼은 `ALL_OBJECTS`/`ALL_TAB_COLUMNS`에서 확인하고 식별자는 `DBMS_ASSERT`로 제한한다.
## 동작
1. 대상 객체가 TABLE 또는 VIEW인지 확인한다.
2. VIEW의 COLUMN 대상은 Oracle에서 변경할 수 없으므로 명확한 오류로 거부한다.
3. `ALL_ANNOTATIONS_USAGE`에서 동일 annotation의 존재 여부를 확인한다.
4. 기존 값이 있으면 DROP 후 ADD, 없으면 ADD만 수행한다.
5. 수행 결과(`ADDED` 또는 `REPLACED`)와 정규화된 대상을 반환한다.
DDL은 Oracle의 implicit commit 특성이 있으므로 호출자는 별도 트랜잭션으로 간주하지 않는다. 이 함수는 게임명·prefix·특정 테이블 정책을 하드코딩하지 않으며, 입력 객체의 존재와 Oracle 문법만 검증한다.
## 호출 예
```sql
SELECT SGMP_SET_ANNOTATION(
'SGMP_POC', 'TABLE', 'CZN_COMN_USER_MST', NULL,
'AU는 최신 BASE_DT에서 AU_FLAG=1이고 EXPT_USER_YN=N인 활성 사용자 수다.',
'AI_GUIDANCE'
) FROM dual;
SELECT SGMP_SET_ANNOTATION(
'SGMP_POC', 'COLUMN', 'CZN_COMN_USER_MST', 'AU_FLAG',
'AU 집계용 활성 사용자 플래그(1=활성).', 'BUSINESS_DEFINITION'
) FROM dual;
```
## 테스트 기준
- TABLE 신규 annotation은 `ADDED`를 반환한다.
- 같은 이름을 다시 호출하면 기존 값을 교체하고 `REPLACED`를 반환한다.
- 존재하지 않는 객체/컬럼, 잘못된 대상 종류, VIEW의 COLUMN 대상은 오류를 반환한다.

View File

@@ -0,0 +1,37 @@
# SGMP Few-shot NL2SQL MCP (#735)
> 상태: Approved
> 구현: `McpSseService`, `SelectAiService`, `McpProperties`
## 목적
기존 Select AI Text2SQL 경로와 분리된 검증용 MCP tool을 제공한다. 질문과 유사한 검토 완료 예제를 벡터 검색하고, 그 SQL 패턴을 prompt에 참고자료로 넣은 뒤 `SHOWSQL`로 생성한 SQL을 읽기 전용으로 실행한다.
## MCP 계약
- **tool name**: `oracle.select_ai.smilegate_fewshot_nl2sql` (환경변수로 재정의 가능)
- **입력**: `prompt` (최대 4,000자)
- **출력**: 벡터 Few-shot 예제(질문, SQL, 모델, cosine distance), 생성 SQL, 실행 상태, 행 수, 최대 100건 결과
Few-shot SQL은 실행하지 않는다. 현재 메타데이터·alias 정책을 우선하고, Select AI가 새로 생성한 SQL만 read-only 검증 후 실행한다.
## 안전 규칙
1. 벡터 검색 실패는 `UNAVAILABLE` 상태로 남기고 기존 정책 prompt로 폴백한다.
2. 생성 결과는 단일 `SELECT` 또는 `WITH`만 허용한다.
3. DDL, DML, PL/SQL, 시스템 객체, 잠금 구문, 다중 문장은 차단한다.
4. JDBC read-only 트랜잭션과 30초 query timeout, 최대 100행 제한을 적용한다.
5. 기존 `data_text2sql`, `data_showprompt`, `qa_vector_search`, `qa_vector_store` tool은 변경하지 않는다.
## 설정
```text
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION
```
## 추적성
- Redmine: #735
- 테스트: `McpSseServiceTest`

View File

@@ -0,0 +1,14 @@
# Smilegate main MCP route: Few-shot NL2SQL (#736)
## 변경
Smilegate 포털의 `smilegate_game_data_mcp` 기본 tool과 선호 route를
`oracle.select_ai.smilegate_fewshot_nl2sql`로 전환한다. 기존 Text2SQL과 QA vector tool은
allowlist에 유지해 호환성과 진단 경로를 보존한다.
## 검증
- tools/list에서 새 tool이 발견되는지 확인
- 포털의 기본 단일 호출 route가 새 tool인지 확인
- 새 tool 응답에 `fewShotExamples`, `generatedSql`, `items`가 존재하는지 확인
- 백오피스에 새 tool이 배포되지 않은 경우 포털에서 명확한 MCP 오류를 표시

View File

@@ -1,123 +0,0 @@
# #740 HMM MCP VPD 실행 경계 복구
## 배경
HMM MCP의 `search_hr_data`는 현재 `ADMIN` JDBC 세션에서
`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 실행한다. 토큰으로
`HMM_ACCESS_CTX`를 설정해도 `ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로
`ADMIN.HMM_LEAVE_BALANCES``ADMIN.HMM_LEAVE_REQUESTS`의 VPD 정책이
실제 조회에 적용되지 않는다.
컨텍스트 값이 올바른 것과 VPD가 적용되는 것은 별개의 조건이다. 보호 테이블을
읽는 최종 SQL은 반드시 `EXEMPT ACCESS POLICY`가 없는 계정의 동일 DB 세션에서
컨텍스트 설정과 함께 실행해야 한다.
## 목표
- `ADMIN`은 Select AI `SHOWSQL` 생성만 담당한다.
- 비면제 런타임 스키마 `CB_ORDS`가 생성 SQL을 읽기 전용으로 실행한다.
- 실행 직전 같은 `CB_ORDS` 세션에서
`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다.
- 실행 종료 시 성공·실패와 관계없이 `CLEAR_VPD_CONTEXT`를 호출하고 롤백한다.
- 런타임 계정이 `ADMIN`이거나 `EXEMPT ACCESS POLICY`를 가진 경우 fail-closed 한다.
- MCP `search_hr_data`만 새 경계로 전환하며 용어·정책 검색 도구는 기존 경로를 유지한다.
## 구조
```text
MCP tools/call
└─ bearer token 해시 인증
├─ ADMIN / DBMS_CLOUD_AI.GENERATE(..., 'showsql')
│ └─ 허용된 HMM HR 객체만 포함한 SELECT/WITH 생성
└─ CB_ORDS JDBC session
├─ CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ...')
│ └─ ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(token)
├─ runtime principal/컨텍스트 일치 검증
├─ SET TRANSACTION READ ONLY
├─ 생성 SQL 실행 → HMM_LEAVE_SCOPE_POLICY 적용
├─ ROLLBACK
└─ CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT
```
## DB 설계
### 런타임 스키마
`CB_ORDS`에는 다음 최소 권한만 부여한다.
- `CREATE SESSION`
- `ADMIN.CB_ORDS_HANDLER_PKG` 실행
- Select AI 프로필의 승인 객체 6개에 대한 `SELECT`
- `HMM_ORG_TEAMS`
- `HMM_HR_EMPLOYEES`
- `HMM_LEAVE_BALANCES`
- `HMM_LEAVE_REQUESTS`
- `HMM_ATTENDANCE_DAILY`
- `HMM_HR_TERMS`
`EXEMPT ACCESS POLICY`, `SELECT ANY TABLE`, 객체 생성 `ANY` 권한은 부여하지 않는다.
생성 SQL의 스키마 한정 여부에 영향을 받지 않도록 승인 객체에 한해
`CB_ORDS` private synonym을 만든다.
### 컨텍스트 패키지
`ADMIN.CB_ORDS_HANDLER_PKG`는 HTTP Authorization 값에서 Bearer 토큰을
추출한 후 `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 호출한다.
토큰 원문은 테이블이나 로그에 저장하지 않는다.
`CB_ORDS`에는 동일 이름의 private synonym만 제공하여 런타임 SQL에서는
`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`로 호출한다. 컨텍스트 패키지 자체의
직접 실행 권한은 런타임 계정에 노출하지 않는다.
`CLEAR_VPD_CONTEXT`는 HMM 컨텍스트와 client identifier를 모두 정리한다.
유효하지 않은 인증 헤더는 컨텍스트를 먼저 지운 뒤 오류로 종료한다.
## 애플리케이션 설계
`backoffice.select-ai` 설정을 두 연결로 분리한다.
- 생성 연결: 기존 `db-url`, `db-username`, `db-password`, `profile`
- 실행 연결: `runtime-db-url`, `runtime-db-username`, `runtime-db-password`
생성 연결은 기존 `BACKOFFICE_DB_*`를 기본값으로 사용할 수 있다. 실행 연결은
명시적으로 설정해야 하며 생성 연결로 자동 폴백하지 않는다.
실행 전 다음을 검증한다.
1. `USER``ADMIN`이 아니다.
2. `SESSION_PRIVS``EXEMPT ACCESS POLICY`가 없다.
3. 패키지 호출 후 `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치한다.
하나라도 실패하면 SQL을 실행하지 않는다.
## 운영 설정
`search_hr_data``executionType``AGENT_TOOL`에서 `SELECT_AI`로 변경한다.
다른 두 도구는 그대로 유지한다.
필수 환경값:
```properties
BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE
BACKOFFICE_SELECT_AI_RUNTIME_DB_URL=<ADB JDBC URL>
BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS
BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<secret>
```
비밀번호는 Git·Redmine·로그에 기록하지 않고 서버 환경 파일에서만 관리한다.
## 검증 기준
1. `CB_ORDS``EXEMPT ACCESS POLICY`가 없음을 확인한다.
2. E1006 토큰으로 `HMM_LEAVE_BALANCES` 조회 결과의 직원은 E1006 한 명뿐이다.
3. E1006 토큰으로 `HMM_LEAVE_REQUESTS` 조회 결과에 E1002가 없다.
4. E1006이 E1002 휴가를 직접 요청하면 0건을 반환한다.
5. 관리자 E1001은 권한 규칙에 따라 자기 자신과 직속 팀원 범위를 조회한다.
6. 무효 토큰, 컨텍스트 불일치, 런타임 권한 오설정은 fail-closed 한다.
## 롤백
- 애플리케이션 환경의 `search_hr_data`를 이전 `AGENT_TOOL` 정의로 되돌리고
이전 JAR를 재기동한다.
- `CB_ORDS` 스키마는 즉시 삭제하지 않고 계정을 잠가 조사 가능 상태로 보존한다.
- VPD 정책 자체와 기존 `HMM_ACCESS_CTX_PKG`는 변경하지 않는다.

View File

@@ -1,112 +0,0 @@
# #742 애플리케이션 단위 저장소 구조 개편
## 문제
현재 저장소 루트의 `pom.xml`, `src/main`, `src/test`는 실제로 하나의 Spring Boot
백오피스지만 제품 폴더 없이 저장소 전체 애플리케이션처럼 보인다. Streamlit 앱은
`poc4_active_source_20260714`라는 날짜 스냅샷 폴더에 있고 내부에서도 `poc3`, `poc4`,
`agent_console`, 단독 Python 모듈이 서로 참조한다.
이 구조에서는 다음 내용을 파일 경로만 보고 알 수 없다.
- 어떤 파일이 실제 배포 애플리케이션인지
- Spring Boot와 Streamlit의 빌드·테스트 경계가 어디인지
- `poc3`, `poc4`가 제품명인지 과거 실험 번호인지
- DB 스크립트와 특정 애플리케이션의 관계가 무엇인지
## 목표 구조
```text
vpd-permission-poc/
├── vpd-backoffice/ Spring Boot VPD·권한 관리 백오피스
│ ├── pom.xml
│ ├── src/main/
│ └── src/test/
├── ai-web-agent-console/ Streamlit AI 업무 에이전트
│ ├── app.py
│ ├── ai_web_agent_console/ 단일 Python 패키지
│ ├── config/
│ ├── tests/
│ └── requirements.txt
├── dds-backoffice/ 별도 DDS 백오피스
├── database/
│ ├── adb/
│ └── source/
├── deploy/ 제품별 배포 설정
├── docs/ 설계·운영·검증 문서
├── scripts/ 저장소 공통 자동화
└── README.md
```
## 이동 매핑
| 기존 | 변경 | 처리 |
|---|---|---|
| `pom.xml` | `vpd-backoffice/pom.xml` | Maven 프로젝트 경계 명시 |
| `src/main` | `vpd-backoffice/src/main` | Spring 애플리케이션 소스 |
| `src/test` | `vpd-backoffice/src/test` | Spring 단위·통합 테스트 |
| `sql/adb` | `database/adb` | Oracle/ADB 스크립트 |
| `sql/source` | `database/source` | 외부 원천 DB 스크립트 |
| `poc4_active_source_20260714` | `ai-web-agent-console` | 날짜·PoC 번호 제거 |
| `apps/poc4/mcp_discovery_ui.py` | `ai-web-agent-console/app.py` | 명확한 Streamlit 진입점 |
| `src/agent_console/*` | `ai-web-agent-console/ai_web_agent_console/*` | 단일 제품 패키지 |
| `src/poc3/model_registry.py` | `ai-web-agent-console/ai_web_agent_console/model_registry.py` | 실험 번호 제거 |
| `src/poc3/questions.py` | `ai-web-agent-console/ai_web_agent_console/questions.py` | 실험 번호 제거 |
| `src/poc4/scenarios.py` | `ai-web-agent-console/ai_web_agent_console/scenarios.py` | 실험 번호 제거 |
| `src/poc4/query_contracts.py` | `ai-web-agent-console/ai_web_agent_console/query_contracts.py` | 실험 번호 제거 |
| `src/mcp_*.py`, `src/oci_genai_sdk.py` | `ai-web-agent-console/ai_web_agent_console/` | 앱 전용 공용 모듈 통합 |
| `config/poc3_model_profiles.json` | `ai-web-agent-console/config/model_profiles.json` | 실험 번호 제거 |
## 경로 규칙
1. 저장소 루트에는 애플리케이션 프레임워크의 `src``pom.xml`을 두지 않는다.
2. 각 애플리케이션은 자신의 소스, 의존성 선언, 테스트 진입점을 가진다.
3. DB 스크립트는 `database`를 Git 원본으로 사용하고 Spring JAR는 해당 경로를
리소스로 포함한다.
4. 배포 설정은 `deploy/<application>`에서 새 소스 경로를 참조한다.
5. 과거 경로를 유지하는 심볼릭 링크나 복제본은 만들지 않는다. 잘못된 경로가 다시
사용되면 테스트가 실패하도록 한다.
6. 운영 중인 환경변수와 SQLite 테이블명은 데이터 호환을 위해 이번 변경에서 유지한다.
폴더·패키지·실행 경로에서만 PoC 명칭을 제거한다.
## Maven 변경
`vpd-backoffice/pom.xml`의 SQL 리소스 경로는 `../database/adb`를 사용한다. 루트 자동화는
`mvn -f vpd-backoffice/pom.xml`을 사용하며 앱 디렉토리 안에서는 기존처럼 `mvn test`
동작해야 한다.
Java 테스트에서 파일을 직접 여는 경로는 앱 디렉토리를 기준으로 유지한다. 따라서
`vpd-backoffice`에서 실행한 테스트와 루트에서 `-f`로 실행한 테스트를 모두 확인한다.
## Python 변경
`ai-web-agent-console/app.py`는 자신의 부모 폴더를 애플리케이션 루트로 사용한다.
모든 import는 `ai_web_agent_console.<module>` 형식으로 통일한다. 테스트도 같은 공개
패키지 경로만 사용한다.
`model_profiles.json` 안의 `default_for_poc3` 속성은 `default_for_console`로 바꾸고
로더·검증 코드도 함께 변경한다. 배포된 비밀정보 파일과 대화 DB는 Git 이동 대상이
아니다.
## 배포 변경
- Spring 배포 빌드는 `vpd-backoffice/pom.xml``vpd-backoffice/target`을 사용한다.
- Streamlit 실행은 `streamlit run app.py`를 사용한다.
- 인증 게이트웨이는 `python -m ai_web_agent_console.auth_gateway`를 사용한다.
- 기존 운영 디렉토리 `/opt/hmm-poc4`는 무중단 전환을 위해 물리 경로로 유지할 수 있지만,
서비스의 Git 소스 경로와 Python 모듈명은 새 구조를 사용한다.
## 검증
1. `git status --short`에서 기존 #741 변경이 새 경로에 남아 있는지 확인한다.
2. `mvn -f vpd-backoffice/pom.xml test`를 실행한다.
3. Python 전체 소스에 `compileall`을 실행한다.
4. `unittest discover -s ai-web-agent-console/tests`를 실행한다.
5. `rg`로 운영 코드의 `src.poc3`, `src.poc4`, `src.agent_console`,
`poc4_active_source_20260714`, `apps/poc4` 참조가 0건인지 확인한다.
6. 배포 스크립트의 dry-run 또는 정적 경로 검증을 수행한다.
## 롤백
모든 이동은 한 Git 커밋으로 추적한다. 문제가 생기면 해당 커밋을 revert하여 이전 경로와
import를 함께 복구한다. DB 스키마와 운영 데이터는 이 구조 변경에서 수정하지 않는다.

View File

@@ -1,64 +0,0 @@
# #743 MCP 광고 Agent Tool 시작 검증
## 문제
백오피스 MCP의 `tools/list``BACKOFFICE_MCP_TOOLS`에 선언된 공개 계약을 광고한다.
`AGENT_TOOL``targetName``DBMS_CLOUD_AI_AGENT.RUN_TOOL`에 전달하지만, 현재는 서버
시작 시 해당 Tool이 ADB에 실제로 등록되어 있는지 확인하지 않는다. 따라서 Discovery는
성공하고 첫 `tools/call`에서만 실패할 수 있다.
## 목표
- 공개 Tool 이름과 입력 스키마는 배포 설정에서 안정적으로 관리한다.
- `AGENT_TOOL`의 내부 `targetName`은 현재 JDBC 실행 사용자의
`USER_AI_AGENT_TOOLS`에서 존재하고 `ENABLED` 상태여야 한다.
- 누락·비활성·메타데이터 조회 실패는 서버 시작을 중단한다.
- Java 자체 구현인 `SELECT_AI`는 ADB Agent Tool 검증 대상에서 제외한다.
## 처리 흐름
```text
Spring 설정 로드
→ EnvironmentMcpToolCatalog가 BACKOFFICE_MCP_TOOLS 검증
→ SmartInitializingSingleton이 AGENT_TOOL targetName 수집
→ USER_AI_AGENT_TOOLS 조회
→ 모두 ENABLED
├─ 예: MCP endpoint 기동 완료, tools/list 광고
└─ 아니오: 기동 실패, 외부에 불완전한 Tool 계약을 광고하지 않음
```
Discovery 요청 때마다 DB를 조회하지 않는다. ADB Agent Tool은 운영 설정이므로 시작 시 한 번
검증하고, 설정이나 DB Tool을 바꾼 뒤에는 애플리케이션을 재기동해 계약을 다시 확정한다.
## 실패 정책
누락된 Tool을 `tools/list`에서 자동 제외하지 않는다. 호출 가능한 Tool 집합이 환경에 따라
조용히 축소되면 Agent instruction과 실제 도구 목록이 어긋나기 때문이다. 설정에 선언된
`AGENT_TOOL`이 하나라도 누락되거나 `ENABLED`가 아니면 fail-closed로 기동을 실패시킨다.
오류에는 설정의 내부 Tool 이름과 상태만 포함한다. Bearer Token, Tool 입력, DB 접속정보는
로그에 기록하지 않는다.
## 구현 경계
- `EnvironmentMcpToolCatalog`: 공개 이름, 인자, 실행 유형, 내부 target 형식 검증
- `McpAgentToolStartupValidator`: DB 등록·상태 검증
- `McpSseService`: 검증 완료된 catalog를 `tools/list`로 광고하고 `tools/call`로 실행
- `JdbcHmmAiAgentToolRunner`: 검증된 `targetName`을 동일 JDBC 세션에서 실행
`USER_AI_AGENT_TOOLS``RUN_TOOL`을 실행하는 기본 datasource 사용자 기준 View다. 다른
스키마의 Tool을 임의로 검색하거나 `ALL_*` 권한을 요구하지 않는다.
## 검증 기준
1. `AGENT_TOOL`이 모두 `ENABLED`면 검증이 통과한다.
2. Tool이 누락되면 누락된 이름을 포함해 실패한다.
3. Tool이 `DISABLED`면 이름과 상태를 포함해 실패한다.
4. 같은 ADB Tool을 여러 공개 Tool이 참조해도 한 번만 검증한다.
5. `SELECT_AI`만 구성되면 `USER_AI_AGENT_TOOLS`를 조회하지 않는다.
6. 기존 Maven 전체 테스트와 MCP discovery/call 테스트가 통과한다.
## 롤백
검증 컴포넌트와 테스트를 제거하면 기존 설정 기반 광고 방식으로 돌아간다. DB Tool,
프로필, VPD 정책과 운영 데이터는 이 변경에서 수정하지 않는다.

View File

@@ -1,128 +0,0 @@
# #745 OCI 스타일 HMM AI 데이터 접근 아키텍처
> 상태: Approved
> Redmine: #745
> 구현 산출물: `docs/architecture/hmm-ai-data-access-architecture.*`
> 생성 도구: `tools/architecture/generate-hmm-oci-architecture.mjs`
## 목적
HMM 데모의 애플리케이션, MCP, ADB 보안 실행 경계, AI 모델, 문서 지식과 외부
PostgreSQL federation을 한 장에서 설명할 수 있는 아키텍처 그림을 만든다. 세부 요청 흐름은
두 번째 페이지에 분리해 사용자 Bearer Token이 실제 VPD 행 필터로 연결되는 지점을 명확히 한다.
기준 스타일은 `/Users/joungminko/Downloads/OCI Style Guide for Drawio`
`OCI Library.xml``Read-ME.drawio`다. OCI 서비스는 공식 라이브러리 도형을 사용하고,
외부 서비스는 `3rd Party Cloud` 경계와 중립 색상으로 구분한다.
## 산출물
| 파일 | 용도 |
|---|---|
| `docs/architecture/hmm-ai-data-access-architecture.drawio` | 편집 가능한 2페이지 원본 |
| `docs/architecture/hmm-ai-data-access-architecture-overview.svg` | 전체 구성 검토·문서 삽입용 |
| `docs/architecture/hmm-ai-data-access-architecture-security-flow.svg` | 요청·보안 흐름 검토용 |
| 같은 이름의 `.png` | 일반 문서·메신저 공유용 |
| `docs/architecture/README.md` | 범례, 현행 기준, 편집·재생성 방법 |
## 페이지 1: 전체 구성
다음 경계를 왼쪽에서 오른쪽으로 배치한다.
1. HMM 사용자: 팀장, 팀원, 관리자
2. 인터넷 공개 HTTPS: `hmm.cloud-handson.com`, `hmm-backoffice.cloud-handson.com`
3. OCI 서울 리전
- Compute VM + Nginx
- AI Web Agent Console
- VPD Backoffice + 사용자 Bearer MCP `/mcp`
- OCI Object Storage의 HR PDF
- Autonomous Database HMMAIPOC
- OCI Generative AI
4. 3rd Party Cloud: AWS RDS for PostgreSQL
ADB 내부에는 아래 실행 요소를 표시한다.
- MCP Tool catalog와 시작 검증: `BACKOFFICE_MCP_TOOLS``USER_AI_AGENT_TOOLS.ENABLED`
- ADB Agent Tool: `resolve_hr_term`, `search_hr_policy`
- Java `SELECT_AI`: `search_hr_data`
- 비면제 런타임 사용자 `CB_ORDS`
- `HMM_ACCESS_CTX`, `HMM_LEAVE_SCOPE_POLICY`, HR 정형 테이블
- 문서 메타데이터, Abstract, Tag, Chunk, Embed 4 Vector
- `HMM_RDS_PG_LINK``HMM_RDS_*_V`, 선사 배정 View
모델 연결은 용도를 구분한다.
- GPT-5.4 Mini: 질문 계획, Select AI SQL 생성, 답변 합성
- Cohere Embed 4: HR 용어와 정책 문서 임베딩
## 페이지 2: MCP 요청과 VPD 적용 흐름
다음 순서를 번호로 표시한다.
1. Agent가 인증된 `initialize`/`tools/list`를 호출한다.
2. 백오피스가 사용자 Bearer Token의 해시, 만료, 회수와 재직 상태를 확인한다.
3. `tools/call`에서 Tool 유형을 분기한다.
4. `AGENT_TOOL``DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다.
5. `search_hr_data`는 ADMIN 세션에서 `DBMS_CLOUD_AI.GENERATE(..., 'showsql')`
승인 객체만 사용하는 읽기 전용 SQL을 만든다.
6. 같은 `CB_ORDS` JDBC 세션에서 `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다.
7. `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER``HMM_ACCESS_CTX`를 설정한다.
8. `CB_ORDS`가 SQL을 실행하면 `HMM_LEAVE_SCOPE_POLICY`가 SELF/MANAGED_TEAM/ALL
predicate를 자동 적용한다.
9. 트랜잭션을 rollback하고 `CLEAR_VPD_CONTEXT`로 세션 문맥을 제거한다.
10. 필터링된 결과와 근거만 Agent에 반환한다.
서버 기동 흐름은 요청 흐름과 분리한다. `AGENT_TOOL targetName`
`USER_AI_AGENT_TOOLS`에 없거나 `ENABLED`가 아니면 서버는 준비 상태가 되지 않으며
불완전한 `tools/list`를 광고하지 않는다.
## 시각 규칙
- 기본 글꼴: `Oracle Sans`, 대체 글꼴 `Arial`, `sans-serif`
- 본문 색: `#312D2A`
- OCI 경계 배경/선: `#F5F4F2` / `#9E9892`
- OCI 강조선: `#AE562C`
- Oracle red 강조: `#C74634`
- 외부 Cloud 경계: 흰색 배경, `#6B7280` 점선
- 정상 데이터 흐름: 실선, 보안·컨텍스트 적용: Oracle red 실선, 관리/적재: 점선
- 선 교차를 최소화하고 연결선에는 동작 이름을 직접 표기한다.
- 스타일 가이드의 안내 전용 pink와 Courier New는 사용하지 않는다.
## 생성기 계약
`generate-hmm-oci-architecture.mjs`는 OCI Library 경로와 출력 디렉터리를 인자로 받는다.
라이브러리에서 아래 공식 도형을 제목으로 찾아 원본 Draw.io에 포함한다.
- `Identity and Security - User`
- `Compute - Virtual Machine VM`
- `Database - Autonomous DB`
- `Analytics and AI - Artificial Intelligence`
- `Storage - Object Storage`
- `Identity and Security - Vault`
필수 도형이 없으면 불완전한 그림을 만들지 않고 실패한다. 생성 결과는 비밀번호, Token,
OCID를 포함하지 않는다.
## 검증
1. `.drawio`가 XML로 파싱되고 페이지가 정확히 2개다.
2. 두 페이지 모두 필수 구성요소와 흐름 라벨을 포함한다.
3. 공식 OCI 라이브러리 stencil이 원본에 포함된다.
4. SVG를 PNG로 렌더링해 잘림, 겹침, 읽기 어려운 글자, 불필요한 선 교차를 확인한다.
5. `git diff --check`와 비밀값 패턴 검사를 통과한다.
## 롤백
`docs/architecture/hmm-ai-data-access-architecture*`와 생성기를 제거하면 된다. 애플리케이션,
DB, OCI 리소스와 운영 설정은 이 작업에서 변경하지 않는다.
## 검증 결과
- Draw.io XML 파싱: PASS
- 페이지 수와 이름: `2` / `01 · 전체 구성`, `02 · MCP 요청과 VPD`
- OCI 공식 stencil 포함: PASS
- 필수 구성요소·보안 흐름 검사: PASS
- 비밀값 패턴 검사: PASS
- 동일 입력 재생성 byte 비교: PASS
- diagrams.net 실제 렌더링: `1840×1120`, `1840×1090`
- PNG 육안 검사: 흰 배경, 제목 대비, 구성요소 잘림 없음, 주요 연결선과 설명 식별 가능

View File

@@ -98,7 +98,7 @@ STEP 8 (선택) ALTER TABLE ... SET USE DATA GRANTS ONLY ENABLED -- MAC 모드
STEP 9 감사 (dba_data_grants 등) + 역순 정리 STEP 9 감사 (dba_data_grants 등) + 역순 정리
``` ```
전체 스크립트는 세션 본문 참조. 이 POC 의 `database/adb/13_dds_variant.sql` + `15_dds_cleanup.sql` 가 동일 패턴. 전체 스크립트는 세션 본문 참조. 이 POC 의 `sql/adb/13_dds_variant.sql` + `15_dds_cleanup.sql` 가 동일 패턴.
--- ---
@@ -325,8 +325,8 @@ CREATE DATA GRANT admin.dg_scoped
### 이 POC 에서 보면 ### 이 POC 에서 보면
- `database/adb/06_policy.sql` + `permission` 테이블 = "앱/운영자가 행으로 권한 관리" → **VPD 답안** - `sql/adb/06_policy.sql` + `permission` 테이블 = "앱/운영자가 행으로 권한 관리" → **VPD 답안**
- `database/adb/13_dds_variant.sql` = "DBA 가 DDL 로 4 종 역할 정의" → **DDS 답안** - `sql/adb/13_dds_variant.sql` = "DBA 가 DDL 로 4 종 역할 정의" → **DDS 답안**
- 같은 결과를 두 다른 모델로 표현한 비교 데모 - 같은 결과를 두 다른 모델로 표현한 비교 데모
**권한 매핑 테이블 중심으로 가겠다면 VPD 그대로 두는 게 정답.** **권한 매핑 테이블 중심으로 가겠다면 VPD 그대로 두는 게 정답.**

View File

@@ -12,10 +12,10 @@
기존 Handler가 trace 필드를 반환하지 않으면 백오피스가 같은 DB 연결에서 `set_user_by_bearer``CB_AGENT_DOC_VPD_FILTER``clear_user` 순서로 기본 VPD predicate를 조회한다. 이 보조 조회가 실패해도 권한 결과 자체는 실패 처리하지 않는다. 기존 Handler가 trace 필드를 반환하지 않으면 백오피스가 같은 DB 연결에서 `set_user_by_bearer``CB_AGENT_DOC_VPD_FILTER``clear_user` 순서로 기본 VPD predicate를 조회한다. 이 보조 조회가 실패해도 권한 결과 자체는 실패 처리하지 않는다.
기존 설치에 trace 권한을 추가하려면 ADMIN으로 `database/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다. 기존 설치에 trace 권한을 추가하려면 ADMIN으로 `sql/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다.
기본으로 표시되는 SQL은 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다. 기본으로 표시되는 SQL은 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다.
실행 DB가 `V$SQL``DBMS_XPLAN` 조회를 허용하면 `/probe`는 최근 2분 내 같은 보호 객체의 SQL_ID, DB가 기록한 원문 SQL, cursor 누적 통계, Predicate Information도 함께 표시한다. `V$SQL` 원문은 VPD 주입 전 SQL이고, 실제 적용 조건은 `DBMS_XPLAN`의 Predicate Information에서 확인한다. 같은 객체에 동시 요청이 많은 경우에는 최근 cursor 매칭이므로 요청 ID 기반 상관 추적이 필요하다. 실행 DB가 `V$SQL``DBMS_XPLAN` 조회를 허용하면 `/probe`는 최근 2분 내 같은 보호 객체의 SQL_ID, DB가 기록한 원문 SQL, cursor 누적 통계, Predicate Information도 함께 표시한다. `V$SQL` 원문은 VPD 주입 전 SQL이고, 실제 적용 조건은 `DBMS_XPLAN`의 Predicate Information에서 확인한다. 같은 객체에 동시 요청이 많은 경우에는 최근 cursor 매칭이므로 요청 ID 기반 상관 추적이 필요하다.
Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../database/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다. Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다.

View File

@@ -54,7 +54,7 @@ EXTERNAL_CLAUSE_NAME=개인용애니카다이렉트자동차보험
변경 스크립트: 변경 스크립트:
- `database/adb/65_kb_select_ai_vpd_query_api.sql` - `sql/adb/65_kb_select_ai_vpd_query_api.sql`
## RAG 근거 검색 확인 결과 ## RAG 근거 검색 확인 결과

View File

@@ -202,7 +202,7 @@ c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_P
대상 스크립트: 대상 스크립트:
```text ```text
database/adb/65_kb_select_ai_vpd_query_api.sql sql/adb/65_kb_select_ai_vpd_query_api.sql
``` ```
### ORDS 응답 표시 ### ORDS 응답 표시
@@ -216,7 +216,7 @@ profile=KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1
대상 스크립트: 대상 스크립트:
```text ```text
database/adb/66_kb_select_ai_vpd_query_ords.sql sql/adb/66_kb_select_ai_vpd_query_ords.sql
``` ```
### MCP 응답 표시 ### MCP 응답 표시
@@ -226,8 +226,8 @@ MCP tool 응답 payload의 profile 표시와 화면 설명을 새 프로파일
대상 소스: 대상 소스:
```text ```text
vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
vpd-backoffice/src/main/resources/templates/mcp-sse.html src/main/resources/templates/mcp-sse.html
``` ```
## 최종 속도 측정 ## 최종 속도 측정

View File

@@ -1,60 +0,0 @@
# HMM 백오피스 전 화면 브라우저 검증
- 검증일: 2026-07-23 KST
- 대상: `https://hmm-backoffice.cloud-handson.com`
- 인증: 운영 관리자 로그인 세션
- 브라우저: Playwright Chromium, 1440×1000
- 추적: Redmine #702
## 판정 기준
각 URL을 직접 열어 HTTP 200, 주 제목 렌더링, 오류 alert 부재를 검사했다. 본문에서 `ORA-`,
`데이터 처리 오류`, `Whitelabel Error Page`, `KBAIPOC`, `POC_2`, KB 보험원장 테이블명을 탐지하고,
브라우저 page error와 console error도 수집했다.
## 메뉴 결과
| 영역 | URL | 결과 |
| --- | --- | --- |
| 대시보드 | `/` | PASS |
| 사용자 | `/users` | PASS |
| 접근 그룹 | `/groups` | PASS |
| 역할 | `/roles` | PASS |
| 행 접근 규칙 | `/permissions` | PASS |
| 컬럼 원문 표시 허용 | `/user-masking-rules` | PASS |
| 권한 현황 | `/effective-matrix` | PASS |
| 보호 상태 | `/vpd-policies` | PASS |
| 컬럼 마스킹 | `/masking-rules` | PASS |
| 토큰 발급 | `/tokens` | PASS |
| 접근 검증 | `/probe` | PASS |
| 조회 대상 | `/objects` | PASS |
| 정형 데이터 조회 | `/structured-data` | PASS |
| 조회 연동 | `/ords-handlers` | PASS |
| 지식자료 관리 | `/vector-knowledge` | PASS |
| 대화형 검색 | `/mcp-chatbot` | PASS |
| 검색 해석 | `/mcp-reasoning` | PASS |
| MCP 서비스 | `/mcp-sse` | PASS |
| 연동 점검 | `/mcp-client-demo` | PASS |
| 운영 현황 | `/operation-status` | PASS |
| 행 접근 필터 구조 | `/vpd-filter-runtime` | PASS |
| DB 메타데이터 | `/schema-metadata` | PASS |
| 보안 SQL 스크립트 | `/security-sql-scripts` | PASS |
| 고급 접근 조건 | `/vpd-filter-policies` | PASS |
| 시스템 설정 | `/settings` | PASS |
| DB 준비 상태 | `/settings/database` | PASS |
총 26개 URL이 모두 통과했다. 오류 alert, KB/POC 잔재, page error, console error는 각각 0건이다.
## HMM 정형 원장 결과
| JSON key | 테이블 | 렌더링 행 | 결과 |
| --- | --- | ---: | --- |
| `teams` | `ADMIN.HMM_ORG_TEAMS` | 1 | PASS |
| `employees` | `ADMIN.HMM_HR_EMPLOYEES` | 7 | PASS |
| `leave-balances` | `ADMIN.HMM_LEAVE_BALANCES` | 7 | PASS |
| `leave-requests` | `ADMIN.HMM_LEAVE_REQUESTS` | 8 | PASS |
| `attendance-daily` | `ADMIN.HMM_ATTENDANCE_DAILY` | 14 | PASS |
| `hr-terms` | `ADMIN.HMM_HR_TERMS` | 21 | PASS |
`HMM_HR_TERMS.EMBEDDING`은 VECTOR이므로 JSON `previewColumns`에서 제외했다. 이 설정으로 원장
미리보기는 21개 용어를 정상 표시하며, VECTOR 자체는 용어 검색 기능에서 계속 사용한다.

View File

@@ -1,60 +0,0 @@
# HMM 백오피스 MCP 사용자 Bearer 검증 보고서
- 일자: 2026-07-23
- Redmine: #712
- 브랜치: `hmm-backoffice`
- 서비스: `https://hmm-backoffice.cloud-handson.com/mcp`
## 원인
백오피스에서 발급한 `vpd_live_*` 직원 토큰을 별도 호환 서버인
`https://hmm-mcp.cloud-handson.com/mcp`에 보냈다. 이 서버의 Nginx는 운영 공용
`HMM_MCP_BEARER_TOKEN` 한 개만 비교하므로, 직원 토큰에서 `Bearer ` 문자열을 제거해도 HTTP 401이
정상이다.
직원 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`다. 조사 시 이 경로는 MCP 도구를
제공했지만 Authorization 값을 실제로 검증하지 않고 버리는 결함도 확인됐다.
## 수정
- 모든 MCP method에서 `HMM_ACCESS_BEARER_TOKENS`의 SHA-256 해시, 만료, 회수, 직원 재직 상태 검증
- 누락·무효·만료·회수 토큰은 HTTP 401
- Tool 호출 connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` 실행
- 같은 connection에서 `DBMS_CLOUD_AI_AGENT.RUN_TOOL` 실행
- 성공·실패와 무관하게 `finally`에서 `HMM_ACCESS_CTX_PKG.CLEAR_USER`
- Agent Factory 표시 주소를 백오피스 MCP 주소로 수정
- 공용 gateway와 직원 VPD endpoint의 주소·토큰 조합을 운영 문서에서 분리
토큰 원문은 Controller에서 `Bearer ` 접두어만 제거해 전달하며 로그·응답·DB에 기록하지 않는다.
DB에는 기존 SHA-256 해시와 식별용 prefix만 남는다.
## 검증
| 항목 | 결과 |
|---|---|
| Maven 자동 테스트 | 107건 통과 |
| 같은 JDBC connection의 context 설정·Tool 실행·context 정리 | 단위 테스트 통과 |
| 운영 JAR SHA-256 | 로컬 검증 빌드와 일치 |
| 서비스 | `vpd-backoffice.service` active |
| 로그인 상태 | HTTP 200 |
| 무토큰 MCP | HTTP 401 |
| 활성 E1002 임시 토큰 initialize | HTTP 200 |
| 활성 E1002 임시 토큰 tools/list | HTTP 200 |
| Tool discovery | HMM Tool 3개 |
| `resolve_hr_term` Tool 호출 | HTTP 200 / MCP result 성공 |
| 회수 후 같은 토큰 | HTTP 401 |
검증용 토큰은 백오피스의 정상 발급 흐름으로 만들고 원문을 출력하지 않았다. 검증 완료 시 즉시
회수했으며, 이전 실패 과정에서 남은 같은 용도의 임시 토큰 한 건도 함께 회수했다.
## Agent Factory 최종 입력
| 항목 | 값 |
|---|---|
| Server name | `hmm-backoffice-mcp` |
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
| Authentication mode | `Bearer Token` |
| Bearer token | 백오피스 발급 토큰 원문만 입력 |
Token 입력란에 `Bearer ` 접두어를 직접 쓰지 않는다. Agent Factory가 HTTP
`Authorization: Bearer <token>` 헤더를 조립한다. OAuth URL과 client ID/secret은 사용하지 않는다.

View File

@@ -1,61 +0,0 @@
# HMM 휴가 팀 계층 VPD 적용·검증 보고서
- 일자: 2026-07-23
- Redmine: #707
- 브랜치: `hmm-backoffice`
- 대상 DB: HMMAIPOC `ADMIN`
- 대상 서비스: `https://hmm-backoffice.cloud-handson.com`
## 적용 결과
`database/adb/72_hmm_leave_team_vpd.sql`을 적용해 다음 구성을 활성화했다.
| 구성 | 결과 |
|---|---|
| 신뢰 컨텍스트 | `HMM_ACCESS_CTX` |
| 토큰 검증 패키지 | `HMM_ACCESS_CTX_PKG` / `VALID` |
| 정책 함수 | `HMM_LEAVE_VPD_FILTER` / `VALID` |
| 휴가 잔여 정책 | `HMM_LEAVE_BALANCES.HMM_LEAVE_SCOPE_POLICY` / `ENABLE=YES` |
| 휴가 신청 정책 | `HMM_LEAVE_REQUESTS.HMM_LEAVE_SCOPE_POLICY` / `ENABLE=YES` |
| 팀장 역할 | E1001 → `HMM_HR_MANAGER` |
| 팀원 역할 | E1002~E1007 → `HMM_HR_VIEWER` |
| 저장 권한 | 두 객체 × `ALL`, `MANAGED_TEAM`, `SELF` = 6건 |
## 실제 행 접근 검증
`ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 이를 결과 검증에 사용하지 않았다. 별도의 비면제
임시 DB 사용자를 만들고, SHA-256 해시만 DB에 저장한 임시 E1001/E1002 토큰으로 조회한 뒤
사용자와 토큰을 즉시 제거했다.
| 검증 세션 | 휴가 잔여 | 휴가 신청 | 교차 사용자 조건 조회 |
|---|---:|---:|---:|
| 컨텍스트 없음 | 0 | 0 | - |
| E1001 팀장 | 7 | 8 | 직접 보고 팀원 포함 |
| E1002 팀원 | 1 | 1 | E1003 신청 0 |
| 무효 토큰 | 0 | - | 컨텍스트 초기화·차단 |
결과: `VPD_VERIFY=PASS`
## 애플리케이션 검증
- Maven: 105 tests, 0 failures, 0 errors
- 배포 JAR SHA-256:
`1b10582e72e4639902ea8f65156af1067941af3ef50cde1d9f767c88f048fb85`
- systemd: `vpd-backoffice.service` active
- 공개 로그인: HTTP 200
- 실제 브라우저 전수 검사:
- 관리 화면 26개: 실패 0
- HMM 정형 원장 6개: 실패 0
- `ORA-*`, 데이터 처리 오류, KB/POC 잔존 문구: 0
- page error: 0
- console error: 0
특히 `/roles`, `/permissions`, `/vpd-policies`, `/vpd-filter-runtime`에서 HMM 역할 3개,
휴가 원장 권한 6개, 정책 2개와 `HMM_LEAVE_VPD_FILTER`가 표시되는 것을 화면으로 확인했다.
## 보안 정리
- DB 비밀번호, 토큰 원문, 해시는 Git·Redmine·보고서에 기록하지 않았다.
- 임시 비면제 DB 사용자는 검증 종료 시 `DROP USER ... CASCADE`로 제거했다.
- 임시 토큰 2건은 검증 종료 시 삭제했다.
- HR 관리자 역할은 전체 접근 규칙만 준비하고 데모 사용자에게 자동 부여하지 않았다.

View File

@@ -1,80 +0,0 @@
# HMM 포털 HttpOnly 쿠키 인증 적용·검증 보고서
- 일자: 2026-07-23
- Redmine: #709
- 브랜치: `hmm-backoffice`
- 서비스: `https://hmm.cloud-handson.com`
## 수정 결과
Streamlit의 `poc4_remember` query-token 생성·복원 코드를 삭제했다. Nginx가 모든 포털
HTTP·WebSocket 요청에 `auth_request`를 수행하고, localhost 인증 서비스가 검증한 사용자와
만료 시각만 Streamlit에 전달한다.
| 구성 | 결과 |
|---|---|
| 인증 서비스 | `hmm-portal-auth.service` / active |
| 인증 서비스 bind | `127.0.0.1:8621` |
| 포털 | `poc4-streamlit.service` / active |
| 공개 경계 | Nginx `auth_request` |
| session cookie | `__Host-HMM_PORTAL_SESSION` |
| cookie 속성 | `Secure; HttpOnly; SameSite=Lax; Path=/` |
| 로그인 CSRF | 10분 일회용 double-submit cookie |
| URL token | 제거 |
| 이전 서명키 | 운영 환경과 환경 백업에서 제거 |
## 자동 테스트
```text
python3 -m unittest discover -s tests -p 'test_*.py' -v
Ran 21 tests
OK (skipped=1)
```
검증 항목:
- PBKDF2 비밀번호 비교
- session token 발급·검증·만료·조작 거부
- persistent/session/logout cookie 속성
- login rate limit
- 실제 HTTP login → auth check → tamper reject → logout 흐름
- login 성공 redirect가 `/`이고 token·remember query가 없는지 확인
## 운영 HTTP 검증
| 요청 | 결과 |
|---|---|
| 쿠키 없이 `/` | 302 → `/auth/login` |
| `/?poc4_remember=retired-token` | 302 → `/auth/login`, query 전달 안 됨 |
| `/auth/login` | 200, `Cache-Control: no-store` |
| 조작 session cookie | 401 |
| 유효 session cookie | Streamlit 200 |
| `/auth/logout` | session cookie `Max-Age=0`, 로그인 화면 이동 |
로그인 페이지 응답에는 `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy:
no-referrer`, 제한된 CSP가 포함된다.
## 실제 브라우저 검증
Playwright에서 다음을 확인했다.
- 미인증 query-token URL은 `/auth/login`으로 이동하고 최종 URL에서 query가 사라짐
- 로그인 화면의 사용자 ID, 비밀번호, 로그인 유지 UI 표시
- 인증 후 최종 URL은 `/`, query 없음
- `__Host-HMM_PORTAL_SESSION`: HttpOnly=true, Secure=true, SameSite=Lax, Path=/
- `document.cookie`에 portal session cookie가 없음
- 아키텍처·시나리오·감사로그·보안관리 탭 필수 내용 표시
- `https://hmm-mcp.cloud-handson.com/mcp` 설정 표시
- 로그아웃 후 session cookie 없음
- page error 0, console error 0
캡처와 기계 판독 보고서는 운영 검증 작업 디렉터리
`/private/tmp/hmm-cookie-auth-audit/`에 생성했다.
## 보안 정리
- 노출된 과거 query-token은 새 인증 경로에서 사용되지 않으며 기존 HMAC 서명키도 회전했다.
- 회전 전 환경 백업 두 개에서는 이전·중간 서명키 줄을 제거했다.
- 현재 secret은 `/opt/hmm-poc4/.env`에만 있고 파일 권한은 `opc:opc 0600`이다.
- 토큰, 비밀번호, cookie 값은 Git·Redmine·보고서·서비스 로그에 기록하지 않았다.
- Nginx access log에는 앞으로 인증 token이 URL로 들어오지 않는다.

View File

@@ -1,49 +0,0 @@
# HMM 포털 전 화면 브라우저 전수검사
- 실행일: 2026-07-23 (Asia/Seoul)
- 대상: `https://hmm.cloud-handson.com/`
- 브라우저: Chromium, 1440×1100, OS dark color-scheme 모의
- 추적: Redmine #699 / branch `hmm-backoffice`
## 결과
| 화면 | 핵심 검증 | 결과 |
|---|---|---|
| 로그인 | 입력 2개, 로그인 유지, 실패 피드백, 서명 로그인, 로그아웃 | PASS |
| 아키텍처 | 운영 아키텍처, 처리 흐름, AI/MCP, 데이터 보안 | PASS |
| 시나리오 | 질문 입력, 샘플, 전송, 결과 empty state | PASS |
| 감사로그 | HMM 이벤트·상태 필터, 기간·건수, 새로고침, 상세 토글 | PASS |
| 보안관리 | 업무 프로세스, 백오피스 링크 및 HTTP 200 | PASS |
| 사이드바 | 데모 사용자 5명, 전환·복원, 검색, 다운로드, MCP cache | PASS |
## 정량 결과
- 검사 화면: 5
- 필수 문구 누락: 0
- 낮은 색상 대비: 0
- 가로 overflow: 0
- 브라우저 page error: 0
- console error: 0
- HTTP 4xx/5xx: 0
시나리오의 “아직 저장된 대화가 없습니다.”와 감사로그의 “선택한 조건에 해당하는 HMM 접근 관리
이벤트가 없습니다.”는 오류가 아니라 현재 데이터 0건을 설명하는 정상 empty state다.
## 이번에 제거한 결함
1. 감사로그가 KB PoC의 `POC_2` FGA 메타데이터를 조회하던 경로를 제거했다.
2. 감사 원천을 `ADMIN.HMM_ACCESS_AUDIT`으로 교체하고 렌더러를 `ai-web-agent-console/ai_web_agent_console/audit.py`
분리했다.
3. Python Thin driver가 암호화 `ewallet.pem`의 암호를 대화형으로 기다리던 연결을 ADB TLS 1521
서버 인증 풀로 교체했다.
4. Streamlit 최신 DOM의 `data-testid="stTab"`을 공통 CSS에서 직접 처리해 dark color-scheme에서
비활성 탭이 흰색으로 렌더링되던 문제를 제거했다.
## 운영 배포
- 애플리케이션: `/opt/hmm-poc4`
- 서비스: `poc4-streamlit.service` (active)
- 감사 DB 환경 파일: `/etc/hmm-poc4-audit.env` (root-only)
- systemd drop-in: `/etc/systemd/system/poc4-streamlit.service.d/20-audit.conf`
DB 사용자·암호·접속 기술자 원문은 이 보고서와 Git에 기록하지 않는다.

View File

@@ -25,9 +25,9 @@ SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@
SQLcl 안에서 실행: SQLcl 안에서 실행:
```sql ```sql
@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql @sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql
``` ```
기대 결과: 기대 결과:
@@ -64,7 +64,7 @@ SQLCL_JAVA_HOME=/path/to/jdk \
가장 단순한 롤백은 직전 Git revision의 `26_agent_ords_security_dynamic_vpd_filter.sql`을 다시 적용하는 것이다. 가장 단순한 롤백은 직전 Git revision의 `26_agent_ords_security_dynamic_vpd_filter.sql`을 다시 적용하는 것이다.
```bash ```bash
git show HEAD~1:database/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql git show HEAD~1:sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql
SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@TNS" SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@TNS"
``` ```

View File

@@ -1,165 +0,0 @@
# HMM MCP endpoint와 토큰 설정
## 인증 수단 구분
HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한다.
| 인증 수단 | 사용 위치 | 전달 방식 | MCP 호출 사용 여부 |
|---|---|---|---|
| `__Host-HMM_PORTAL_SESSION` | `hmm.cloud-handson.com` 포털 로그인 | 브라우저 `Secure; HttpOnly` 쿠키 | 사용 금지 |
| `HMM_MCP_BEARER_TOKEN` | 별도 호환 MCP 서버 접근 | HTTP `Authorization: Bearer` | 포털의 공용 gateway에 사용 |
| HMM 직원별 VPD 토큰 | 백오피스 MCP 사용자 인증·DB context | HTTP `Authorization: Bearer` | Agent Factory 사용자별 연동에 사용 |
포털 쿠키를 복사해 MCP Bearer Token으로 사용하면 안 된다. 브라우저 JavaScript에서도 포털 쿠키를
읽을 수 없도록 `HttpOnly`로 설정한다.
## MCP endpoint 구분
두 주소는 같은 토큰을 받지 않는다.
| 항목 | 값 |
|---|---|
| 사용자별 VPD MCP | `https://hmm-backoffice.cloud-handson.com/mcp` |
| 사용자별 인증 | 백오피스에서 발급한 `vpd_live_*` 토큰 원문 |
| 공용 호환 MCP | `https://hmm-mcp.cloud-handson.com/mcp` |
| 공용 인증 | 운영 `HMM_MCP_BEARER_TOKEN` |
| Transport | Streamable HTTP POST |
허용 도구는 다음 세 개다.
| 도구 | 주요 인자 | 용도 |
|---|---|---|
| `search_hr_data` | `query` | 조직, 직원, 휴가 잔여·신청, 근태 조회 |
| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 |
| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 |
`tools/list``BACKOFFICE_MCP_TOOLS`의 공개 이름·설명·입력 스키마를 광고한다.
`executionType=AGENT_TOOL`인 항목은 애플리케이션 시작 시 기본 datasource 사용자의
`USER_AI_AGENT_TOOLS`에서 `targetName`이 실제로 존재하고 `STATUS=ENABLED`인지 검증한다.
하나라도 누락되거나 비활성이면 서버는 기동을 실패하며 불완전한 Tool 목록을 광고하지 않는다.
`search_hr_data`처럼 `executionType=SELECT_AI`인 Java 자체 구현 Tool은 이 검증 대상이 아니다.
## 포털의 현재 공용 토큰 조합
`config/vpd_token_presets.json`의 현재 조합은 다음과 같다.
| 데모 사용자 | 역할 | `mcp_token_env` |
|---|---|---|
| E1001 Kim Minseo | HR Team Manager | `HMM_MCP_BEARER_TOKEN` |
| E1002 Lee Jiwon | HR Operations Specialist | `HMM_MCP_BEARER_TOKEN` |
| E1003 Park Dohyun | People Analytics Analyst | `HMM_MCP_BEARER_TOKEN` |
| E1005 Han Seojun | Recruiting Specialist | `HMM_MCP_BEARER_TOKEN` |
| E1007 Kang Minho | HR Coordinator | `HMM_MCP_BEARER_TOKEN` |
현재는 모든 preset이 같은 서버 관리 토큰을 쓴다. preset을 바꾸면 질문에 포함되는 데모 사용자
문맥은 바뀌지만, Bearer Token 자체는 바뀌지 않는다. 따라서 이 조합만으로는 사용자별 VPD
보안 경계를 만들지 못한다.
## 포털 설정
`config/mcp_servers.json`에는 토큰 원문 대신 환경변수 이름만 기록한다.
```json
{
"id": "hmm_hr_mcp",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
"tool_allowlist": [
"search_hr_data",
"resolve_hr_term",
"search_hr_policy"
]
}
```
운영 서버 `/opt/hmm-poc4/.env`에 실제 값이 있어야 한다.
```dotenv
HMM_MCP_BEARER_TOKEN=<MCP 서버에 등록된 동일한 임의 토큰>
POC3_MCP_TIMEOUT_SECONDS=45
```
토큰 원문은 JSON, Git, 대화 기록, 화면 상세에 저장하지 않는다. 환경 파일은 운영 계정만 읽을
수 있게 제한한다.
## 사용자 VPD MCP 직접 호출 예시
```bash
export HMM_USER_BEARER_TOKEN='<백오피스 발급 화면에서 한 번 표시된 원문>'
curl --fail-with-body \
-H "Authorization: Bearer ${HMM_USER_BEARER_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
--data '{
"jsonrpc": "2.0",
"id": "tools-list-1",
"method": "tools/list",
"params": {}
}' \
https://hmm-backoffice.cloud-handson.com/mcp
unset HMM_USER_BEARER_TOKEN
```
MCP 서버가 `initialize`와 session ID를 요구하면 다음 순서를 사용한다.
1. `initialize`
2. 응답의 `Mcp-Session-Id` 보관
3. `notifications/initialized`
4. 같은 session header와 Bearer Token으로 `tools/list`
5. 같은 session header와 Bearer Token으로 `tools/call`
## Oracle AI Database Private Agent Factory 설정
| 입력 항목 | 값 |
|---|---|
| Server name | `hmm-backoffice-mcp` |
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
| Authentication mode | Bearer Token |
| Token | 백오피스에서 해당 직원에게 발급한 토큰 원문. `Bearer ` 문자열은 붙이지 않음 |
| Allowed tools | 위 세 도구만 선택 |
| Timeout | 45초부터 시작 |
이 endpoint는 OAuth authorization endpoint가 아니다. OAuth client ID, client secret,
authorization URL, token URL은 입력하지 않는다.
## 사용자별 VPD 적용 구조
MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token + 직원 VPD token” 두 개를
같은 header에 조합하지 않는다. 다음 구조가 권장된다.
1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다.
2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다.
3. `ADMIN` 연결은 Select AI `SHOWSQL` 생성까지만 수행한다.
4. `EXEMPT ACCESS POLICY`가 없는 `CB_ORDS` 연결에서
`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ' || :token)`을 호출한다.
5. 같은 `CB_ORDS` DB 세션에서 생성된 읽기 전용 SQL로
`HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다.
6. 실행 전 런타임 사용자가 `ADMIN`이 아니고 `EXEMPT ACCESS POLICY`가 없으며,
`HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치하는지 확인한다.
7. 하나라도 다르면 조회하지 않고 fail-closed 한다.
8. `finally`에서 `CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT`와 rollback을 수행한다.
9. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서
application identity와 user subject를 하나의 검증 가능한 access token으로 합친다.
이 흐름은 2026-07-31 백오피스 MCP `search_hr_data`에 적용됐다. 모든 MCP method가 토큰
해시·만료·회수·재직 상태를 확인한다. `search_hr_data`
`HMM_HR_DATA_GPT54_PROFILE`로 SQL만 생성하고, 실제 SQL은 위 `CB_ORDS` 세션에서 실행한다.
무토큰·무효·회수 토큰은 HTTP 401이다.
운영 환경에는 다음 값이 필요하다.
```dotenv
BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE
BACKOFFICE_SELECT_AI_RUNTIME_DB_URL=<ADB JDBC URL>
BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS
BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<운영 secret>
```
DB 구성 스크립트는 `database/adb/73_hmm_mcp_vpd_runtime.sql`이다. 이 스크립트는 승인된 HMM
HR 객체에 대한 개별 `SELECT`와 handler package 실행 권한만 부여하며
`EXEMPT ACCESS POLICY`는 부여하지 않는다.
포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다.
Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다.

View File

@@ -1,96 +0,0 @@
# MCP·VPD·Data Redaction·DDS 공통 운영 가이드
## 1. 적용 범위와 책임
이 문서는 고객사와 데이터 도메인에 무관하게 사용하는 공통 운영 절차다. 고객별 테이블,
지표, 질문 예제, Select AI 프로파일은 각 고객사 브랜치 문서에서 관리한다.
| 구성요소 | 책임 | 운영자가 확인할 것 |
| --- | --- | --- |
| MCP | 허용 도구를 공개하고 요청·응답 계약을 제공 | `tools/list`, 입력 스키마, 인증 |
| Select AI | 읽기 전용 SQL 생성 및 실행 | 허용 객체, SQL 검증, timeout |
| VPD | 행 단위 접근 제어 | session context, policy, predicate |
| Data Redaction (ASO) | 민감 컬럼 값의 NULL/마스킹 | 대상 컬럼, policy, 예외 사용자 |
| DDS | 선언형 행·컬럼·작업 권한 | END USER, DATA ROLE, DATA GRANT |
| FGA | 실행 증적 감사 | 요청 식별자, SQL, RLS 정보 |
VPD와 Data Redaction은 서로 대체하지 않는다. VPD는 **어떤 행을 볼 수 있는지**,
Data Redaction은 허용된 행에서 **컬럼 값을 어떻게 표시할지**를 담당한다. DDS는
`DATA GRANT`로 행·컬럼 권한을 선언적으로 적용하는 별도 경로다.
## 2. 공통 실행 흐름
```text
사용자/Agent
→ MCP initialize · tools/list · tools/call
→ Bearer 또는 서비스 인증 검증
→ 허용된 도구와 입력 스키마 확인
→ DB session context 또는 DDS END USER context 설정
→ Select AI SHOWSQL 생성
→ SELECT/WITH 전용 검증과 read-only 실행
→ VPD 행 필터 + Data Redaction 또는 DDS 권한 적용
→ 결과·생성 SQL·감사 식별자 반환
```
MCP 또는 애플리케이션은 사용자 입력을 VPD predicate나 SQL 조각으로 조합하지 않는다.
권한 판단은 DB 정책·권한 테이블·DDS grant에서 수행한다.
## 3. 최초 구성 순서
1. `database/adb/`의 기본 스키마, 권한 테이블, 보호 View를 적용한다.
2. VPD context package·policy function을 compile하고 `DBA_POLICIES`에서 적용 대상을 확인한다.
3. 민감 컬럼에는 Data Redaction policy를 적용하고 권한별 조회 결과를 확인한다.
4. DDS를 사용할 경우 END USER, DATA ROLE, DATA GRANT를 별도 구성한다. 단순 Bearer 값 조회만으로 DDS END USER가 되지 않는다.
5. MCP endpoint와 공개 도구 목록을 설정한다. 광고한 모든 도구는 실제로 호출 가능해야 한다.
6. SELECT/WITH 이외 문장 차단, read-only transaction, query timeout, 반환 행 제한을 설정한다.
7. 사용자별 허용/거부, 마스킹, 감사 증적을 회귀 검증한다.
## 4. MCP 운영 절차
### 기동 전
- 환경변수·DB 연결·wallet·MCP endpoint를 점검한다.
- 도구 이름, 설명, 입력 스키마가 실제 구현과 일치하는지 확인한다.
- 실제 token, password, wallet, 대화 이력 DB는 Git에 넣지 않는다.
### 요청 처리
1. `initialize` 후 협상된 프로토콜 버전을 사용한다.
2. `tools/list` 결과 중 승인된 도구만 호출한다.
3. `tools/call` 입력을 서버에서 검증한다.
4. 생성 SQL과 실행 결과, 오류 사유를 분리해 반환한다.
5. 실패 시 임의 SQL 재시도 대신 도구 상세·SHOWPROMPT·DB audit을 확인한다.
## 5. VPD와 Data Redaction 점검
| 증상 | 우선 확인 |
| --- | --- |
| 권한 있는데 0행 | session context, VPD predicate, 권한 매핑 |
| `ORA-00942`/`ORA-01031` | DB object grant와 보호 View 노출 여부 |
| 컬럼이 NULL/마스킹됨 | Data Redaction policy와 예외 조건 |
| 다른 사용자 결과가 같음 | Bearer→업무 사용자 매핑, context clear |
| 요청 추적 불가 | `CLIENT_IDENTIFIER`, FGA/Unified Audit Trail |
VPD 정책 변경 전에는 대상 View·TABLE, 기존 policy, 함수 상태를 백업하고, 변경 후
허용 사용자·거부 사용자·마스킹 예외 사용자를 모두 조회한다. 롤백은 policy/function 및
권한 매핑을 직전 검증 상태로 되돌린 뒤 같은 회귀 시나리오로 확인한다.
## 6. DDS 사용 기준
- 사용자별 `END USER` context를 실제 DB 호출에 전달할 수 있으면 DDS를 고려한다.
- 서비스 계정의 client-credentials token은 서비스 인증용이며 업무 사용자 권한을 뜻하지 않는다.
- 권한이 자주 바뀌면 사용자별 grant 복제보다 공통 role과 런타임 권한 함수를 검토한다.
- VPD와 DDS를 같은 보호 객체에 함께 적용할 때는 정책 순서와 예상 결과를 별도 검증한다.
## 7. 검증과 증적
최소 검증 세트는 다음과 같다.
1. 인증 성공/실패와 `tools/list` 계약
2. 허용 사용자와 거부 사용자의 행 수 차이
3. 민감 컬럼의 Redaction 결과
4. 읽기 전용 SQL 차단 규칙과 timeout
5. FGA 또는 Unified Audit Trail의 SQL·RLS 정보·요청 식별자
상세 SQLcl 절차는 [VPD 배포·검증·롤백 런북](460-sqlcl-vpd-deploy-runbook.md)을,
설계 근거는 [ORDS·VPD·DDS 보안 설계](../06-agent-ords-vpd-dds-security-brief.md)를 따른다.

View File

@@ -0,0 +1,129 @@
# PoC_4 runtime environment template
#
# 사용법:
# cp .env.sample .env
# chmod 600 .env
# 편집 후 scripts/poc4/start_*_nohup.sh 로 기동합니다.
# 제품별 화면 값: 기본값은 config/app_profile.json, 아래 값이 있으면 환경변수가 우선합니다.
# AGENT_CONSOLE_NAME=HMM AI 업무 에이전트
# AGENT_CONSOLE_SHORT_NAME=HMM
# AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트
# AGENT_CONSOLE_PAGE_ICON=⛴️
# AGENT_CONSOLE_HEADER_TITLE=AI 업무 에이전트
# AGENT_CONSOLE_HEADER_DESCRIPTION=사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.
# AGENT_CONSOLE_LOGIN_KICKER=HMM SHIPPING & LOGISTICS DEMO
# AGENT_CONSOLE_LOGIN_TITLE=HMM AI 업무 에이전트
# AGENT_CONSOLE_LOGIN_DESCRIPTION=사용자 인증 후 해운·물류 AI 질의와 보안 관리 기능을 이용할 수 있습니다.
# AGENT_CONSOLE_LOGIN_FOOTER=인증된 DEMO 사용자만 접근할 수 있습니다.
# AGENT_CONSOLE_PRIMARY_COLOR=#003b70
# AGENT_CONSOLE_TEXT_COLOR=#172b3a
# AGENT_CONSOLE_MUTED_COLOR=#667785
# AGENT_CONSOLE_BORDER_COLOR=#dfe7ed
#
# 실제 token, password, OCID, wallet 경로는 이 샘플에 기록하지 않습니다.
# 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다.
# -----------------------------------------------------------------------------
# MCP runtime (현재 PoC_4가 공유하는 PoC_3 호환 환경변수 계약)
# -----------------------------------------------------------------------------
POC3_MCP_PROVIDER=custom_python
POC3_MCP_BASE_URL=http://127.0.0.1:8500
POC3_MCP_AUTH_MODE=bearer
POC3_MCP_TOKEN=
POC3_MCP_TIMEOUT_SECONDS=30
POC3_MCP_FALLBACK_TO_MOCK=false
POC3_MCP_LIVE_SMOKE=false
# MCP server registry
# 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다.
# 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다.
POC4_MCP_SERVERS_FILE=config/mcp_servers.json
POC4_MCP_DEFAULT_SERVER_ID=local_adb_mcp
# MCP discovery UI conversation history store
# 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3
POC4_CHAT_DB_PATH=data/poc4_mcp_chat.sqlite3
# -----------------------------------------------------------------------------
# Local trace / optional LangSmith metadata
# -----------------------------------------------------------------------------
POC3_TRACE_MODE=local
POC3_TRACE_UI_ENABLED=true
LANGSMITH_TRACING=false
LANGSMITH_API_KEY=
LANGSMITH_PROJECT=kb-aidp-poc4
# -----------------------------------------------------------------------------
# PoC_4 UI ports
# 현재 VM 기본 포트만 사용합니다. 공식 포트 전환은 .env만으로 허용되지 않으며
# POC4_ALLOW_OFFICIAL_PORTS=1을 기동 명령의 환경에 별도로 지정해야 합니다.
# -----------------------------------------------------------------------------
POC4_LANGGRAPH_UI_PORT=8612
POC4_AGENT_TEAM_UI_PORT=8613
POC4_LANGGRAPH_TC_UI_PORT=8622
POC4_AGENT_TEAM_TC_UI_PORT=8623
# -----------------------------------------------------------------------------
# OCI Generative AI
# compartment ID는 배포 환경의 값을 입력합니다. API key/private key 원문은 넣지
# 않고 OCI config file 또는 instance/resource principal을 사용합니다.
# -----------------------------------------------------------------------------
OCI_AUTH_TYPE=config_file
OCI_CONFIG_FILE=~/.oci/config
OCI_PROFILE=DEFAULT
OCI_GENAI_COMPARTMENT_ID=
# 모델 route는 config/poc3_model_profiles.json의 검증된 기본값을 사용합니다.
# 배포 환경에서 route를 바꿔야 할 때만 아래 항목의 주석을 해제합니다.
# POC3_LLM_GPT55_OCI_MODEL_ID=openai.gpt-5.5
# POC3_LLM_GPT55_OCI_REGION=us-chicago-1
# POC3_LLM_GPT55_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
# POC3_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini
# POC3_LLM_GPT54_MINI_OCI_REGION=us-chicago-1
# POC3_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
# POC3_LLM_GROK43_MODEL_ID=xai.grok-4.3
# POC3_LLM_GROK43_REGION=us-chicago-1
# POC3_LLM_GROK43_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
# 화면 LLM 모델 선택의 기본값입니다.
# 선택 모델은 후속 질문 정리, 실행 방식 판단, MCP tool 라우팅,
# Agent planning, 최종 답변 합성에 사용됩니다.
# 실패 시 애플리케이션 기본 fallback인 gpt54_mini_oci로 재시도합니다.
POC4_COMPLEX_REASONING_MODEL_PROFILE=grok43
# -----------------------------------------------------------------------------
# Audit / security evidence DB connection
# -----------------------------------------------------------------------------
# VPD 개발본 배포 서버 기준:
# - DB 접속 secret은 /home/opc/kbmcp/.env 에 둡니다.
# - DB wallet은 /home/opc/wallet/kbaipoc 를 사용합니다.
# 이 샘플에는 DB password, wallet password, token 원문을 넣지 않습니다.
POC4_AUDIT_DB_ENV_FILE=/home/opc/kbmcp/.env
ORACLE_WALLET_DIR=/home/opc/wallet/kbaipoc
# -----------------------------------------------------------------------------
# Direct Oracle DB administration tools (목표 계약)
# Streamlit/MCP-only 배포에서는 모두 비워 둡니다. 현재 launcher는 .env 전체를 UI
# process에 export하므로 역할별 설정 loader가 구현되기 전에는 이 파일에 DB secret을
# 채우지 않습니다. DB migration/audit 전용 process environment에서만 주입합니다.
# -----------------------------------------------------------------------------
POC4_DB_ADMIN_ENABLED=false
POC4_DB_USERNAME=
POC4_DB_PASSWORD=
POC4_DB_DSN=
POC4_DB_WALLET_DIR=
POC4_DB_WALLET_PASSWORD=
POC4_DB_EXPECTED_SCHEMA=
POC4_DB_CLIENT_LIB_DIR=
POC4_DB_MODE=thick
POC4_DB_CONNECT_TIMEOUT_SECONDS=60
POC4_DB_CALL_TIMEOUT_MS=120000
# 신규 tenancy 이관 preflight에서만 사용하며 값 자체는 출력하지 않습니다.
POC4_EXPECTED_TENANCY_ID=
# 다음 값은 복제된 .env가 보안/프로세스 제어를 바꾸지 못하도록 launcher 호출자만
# 지정할 수 있습니다. 이 파일에 활성 값으로 추가하지 않습니다.
# POC4_BIND_ADDRESS=0.0.0.0
# POC4_ALLOW_OFFICIAL_PORTS=1
# POC4_MANAGED_FOREGROUND=1

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