Compare commits
75 Commits
hmm-backof
...
9d2c75459f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2c75459f | ||
|
|
041ab4f287 | ||
|
|
98c8ccde85 | ||
|
|
987ea44f32 | ||
|
|
5409a29297 | ||
|
|
154572c885 | ||
|
|
6d98931ce1 | ||
|
|
d13d6d48ca | ||
|
|
57f6677afb | ||
|
|
00a939f9a1 | ||
|
|
8dffa2f515 | ||
|
|
3cc033e4e6 | ||
|
|
f31292ecb3 | ||
|
|
040d4f3d4b | ||
|
|
fbb04a55a2 | ||
|
|
956693bb23 | ||
|
|
ec5d731bf5 | ||
|
|
3a7e2ced00 | ||
|
|
55faaa0794 | ||
|
|
10581dc605 | ||
|
|
beaef2d171 | ||
|
|
ed3139f9f7 | ||
|
|
bd651eb66f | ||
|
|
2e26e3261f | ||
|
|
97cd5c9f25 | ||
|
|
7ddb8ef0fc | ||
|
|
455e2aa222 | ||
|
|
a17f18cffe | ||
|
|
943c38f181 | ||
|
|
443d6677a3 | ||
|
|
9c48d86696 | ||
|
|
c58cdb5797 | ||
|
|
e753027536 | ||
|
|
da992ff524 | ||
|
|
00b62bfb41 | ||
|
|
d552e5e25b | ||
|
|
3fcd699d92 | ||
|
|
c8fa6af679 | ||
|
|
8e4d2f656f | ||
|
|
df19912378 | ||
|
|
744dcad634 | ||
|
|
23bc4424e3 | ||
|
|
53342e7bc3 | ||
|
|
7ba173240c | ||
|
|
ebc8aeee41 | ||
|
|
75d0a9a998 | ||
|
|
32fccced20 | ||
|
|
a46ebe6bfd | ||
|
|
1771dccd96 | ||
|
|
cbee6c31c7 | ||
|
|
f730c73ceb | ||
|
|
b106e40631 | ||
|
|
99798aa6bb | ||
|
|
1fe8404e4b | ||
|
|
1fcc231eff | ||
|
|
63f294a857 | ||
|
|
20836e458d | ||
|
|
9eb1f4d1fd | ||
|
|
914dc087ed | ||
|
|
27757ebff3 | ||
|
|
4211f11cae | ||
|
|
a91ad3c364 | ||
|
|
90d76f1033 | ||
|
|
211dd2b2c8 | ||
|
|
20c6a82338 | ||
|
|
b3f2422b63 | ||
|
|
1538aadb14 | ||
|
|
404244534a | ||
|
|
ea4fc1de8b | ||
|
|
0eb68664f1 | ||
|
|
4eec33b2ed | ||
|
|
b4ed8649f1 | ||
|
|
f7295277f1 | ||
|
|
075eb46ef8 | ||
|
|
2b66e27bfb |
59
.env.example
59
.env.example
@@ -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,41 +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 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
|
|
||||||
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
|
|
||||||
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
|
|
||||||
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
export BACKOFFICE_MCP_TOOL_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
|
||||||
@@ -105,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 가능
|
||||||
@@ -118,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"
|
||||||
@@ -139,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"
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
# 환경/비밀 — 절대 commit 금지
|
# 환경/비밀 — 절대 commit 금지
|
||||||
.env
|
.env
|
||||||
.runtime/
|
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
@@ -18,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/
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -1,28 +0,0 @@
|
|||||||
# 문서 작성 공통 규칙
|
|
||||||
|
|
||||||
이 저장소에서 새 문서를 만들거나 기존 문서를 크게 고칠 때는 아래 구조를 기본으로 한다.
|
|
||||||
|
|
||||||
1. 첫 문서(`README.md`)는 독자가 2~3분 안에 목적, 범위, 결정사항, 전체 구성, 현재 상태를 파악하는 **개요 문서**로 작성한다.
|
|
||||||
2. README에는 복잡한 절차와 모든 오류 사례를 누적하지 않는다. 아래와 같이 역할별 상세 문서로 분리하고, 개요에서 명확한 링크를 제공한다.
|
|
||||||
- `architecture.md`: 신뢰 경계, 컴포넌트 책임, 데이터·인증 흐름, 설계 결정
|
|
||||||
- `cookbook.md`: 준비물, 단계별 적용 명령/화면값, 검증, 롤백
|
|
||||||
- `troubleshooting.md`: 증상 → 원인 → 확인 방법 → 해결 → 재발 방지
|
|
||||||
- 필요하면 `operations.md`, `security.md`, `adr/` 등 목적이 드러나는 파일을 추가한다.
|
|
||||||
3. 그림은 한 장에 모든 세부사항을 넣지 않는다.
|
|
||||||
- README에는 시스템 경계와 핵심 흐름만 보이는 **개요도**를 둔다.
|
|
||||||
- 상세 연결·claim·포트·예외 흐름은 architecture 또는 cookbook의 **상세도**로 분리한다.
|
|
||||||
- 각 그림 아래에는 독자가 알아야 할 결론을 한두 문장으로 적는다.
|
|
||||||
4. Cookbook은 실제 적용 순서를 따르며, 각 단계에 입력값의 출처, 성공 판정, 실패 시 연결할 troubleshooting 항목을 포함한다.
|
|
||||||
5. 비밀번호, client secret, access token, 개인 식별 정보는 어떤 문서·그림·명령 출력에도 기록하지 않는다. 위치와 안전한 조회/rotation 방법만 기록한다.
|
|
||||||
6. README의 문서 지도와 각 상세 문서의 상호 링크를 변경 후 반드시 확인한다.
|
|
||||||
|
|
||||||
문서의 독자는 운영자·개발자·검토자다. 제품명과 전문 용어는 필요할 때만 쓰고, 처음 한 번은 평이한 말로 역할을 정의한다.
|
|
||||||
|
|
||||||
## HMM Select AI·HTML 리포트 보호 기준
|
|
||||||
|
|
||||||
1. 사용자 권한이 적용되는 HMM MCP의 기준 endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp`다.
|
|
||||||
2. MCP route `search_carrier_performance`는 기존 DB Agent Tool `HMM_CARRIER_FEDERATION_SEARCH`를 호출한다. 이 Tool은 자연어를 Select AI Federation으로 처리하며, 고정 SQL·고정 데이터·대체 package로 바꾸지 않는다.
|
|
||||||
3. `hmm-mcp.cloud-handson.com`은 별도 호환성·무 VPD 시험 환경이다. 기준 endpoint나 운영 Select AI 경로의 대체재로 사용하지 않는다.
|
|
||||||
4. HTML 기능 추가 범위는 후속 표현 Tool `HMM_CARRIER_REPORT_RENDERER`와 포털의 범용 이전 결과 전달뿐이다. 렌더러는 사용자·권한·업무 데이터를 다시 조회하거나 보충하지 않는다.
|
|
||||||
5. 조회 Tool, target, package 또는 endpoint를 생성·삭제·교체하기 전에는 현재 MCP discovery, `BACKOFFICE_MCP_TOOLS`, DB Agent Tool metadata를 먼저 대조한다. 기존 조회 경로 변경은 사용자가 명시적으로 요청한 경우에만 한다.
|
|
||||||
6. 회귀 검증은 같은 Bearer 문맥에서 `search_carrier_performance`의 원본 행 수와 renderer에 전달된 `rows` 수가 같은지 확인한다. 대표 시나리오의 현재 기준은 E1001 팀장 질문에 대한 8건이지만, 코드는 E1001이나 8을 조건으로 사용하지 않는다.
|
|
||||||
54
README.md
54
README.md
@@ -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
|
||||||
|
|||||||
@@ -1,53 +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=
|
|
||||||
HMM_MCP_BEARER_TOKEN_E1001=
|
|
||||||
HMM_MCP_BEARER_TOKEN_E1002=
|
|
||||||
HMM_MCP_BEARER_TOKEN_E1003=
|
|
||||||
HMM_MCP_BEARER_TOKEN_E1005=
|
|
||||||
HMM_MCP_BEARER_TOKEN_E1007=
|
|
||||||
AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45
|
|
||||||
|
|
||||||
# OCI Generative AI SDK
|
|
||||||
OCI_AUTH_TYPE=config_file
|
|
||||||
OCI_CONFIG_FILE=~/.oci/config
|
|
||||||
OCI_PROFILE=DEFAULT
|
|
||||||
OCI_GENAI_COMPARTMENT_ID=
|
|
||||||
AI_WEB_AGENT_CONSOLE_MODEL_PROFILE=gpt54_mini_oci
|
|
||||||
AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE=gpt54_mini_oci
|
|
||||||
|
|
||||||
# Optional model route overrides
|
|
||||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini
|
|
||||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION=us-chicago-1
|
|
||||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
|
||||||
|
|
||||||
# Login gateway. Generate PBKDF2 hash and random cookie secret outside Git.
|
|
||||||
AI_WEB_AGENT_CONSOLE_LOGIN_USER=demo-admin
|
|
||||||
AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2=
|
|
||||||
AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET=
|
|
||||||
PORTAL_AUTH_BIND_ADDRESS=127.0.0.1
|
|
||||||
PORTAL_AUTH_PORT=8621
|
|
||||||
PORTAL_AUTH_SESSION_SECONDS=43200
|
|
||||||
PORTAL_AUTH_REMEMBER_SECONDS=604800
|
|
||||||
|
|
||||||
# Product display
|
|
||||||
AGENT_CONSOLE_NAME=HMM AI 업무 에이전트
|
|
||||||
AGENT_CONSOLE_SHORT_NAME=HMM
|
|
||||||
AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트
|
|
||||||
AGENT_CONSOLE_PRIMARY_COLOR=#004b87
|
|
||||||
|
|
||||||
# Local conversation store
|
|
||||||
AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH=data/ai_web_agent_console_chat.sqlite3
|
|
||||||
|
|
||||||
# Optional audit DB. Password and Wallet remain outside Git.
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE=.env
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER=ADMIN
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD=
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DSN=
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR=
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD=
|
|
||||||
|
|
||||||
# Optional query contract override
|
|
||||||
AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH=config/hmm_hr_query_contracts.json
|
|
||||||
@@ -1,59 +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_E1001`처럼 사용자별 환경변수 이름만 참조한다. token 원문은 설정 JSON이나
|
|
||||||
문서에 기록하지 않는다. 사용자 preset들이 같은 환경변수를 공유하면 화면의 사용자만 바뀌고 DB
|
|
||||||
권한 문맥은 바뀌지 않으므로 허용하지 않는다.
|
|
||||||
|
|
||||||
## 검증
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m compileall -q app.py ai_web_agent_console tests
|
|
||||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 운영 호환성
|
|
||||||
|
|
||||||
운영 서버의 물리 경로 `/opt/hmm-poc4`와 기존 SQLite 테이블명은 대화 이력과 서비스 전환
|
|
||||||
호환을 위해 유지할 수 있다. Git 폴더, Python 패키지와 실행 명령은 이 문서의 새 이름만
|
|
||||||
사용한다.
|
|
||||||
@@ -1,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)),
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>HMM | 팀 포트폴리오 선사 실적</title>
|
|
||||||
<style>
|
|
||||||
:root { --hmm:#0065ad; --hmm-deep:#004a84; --ink:#102c43; --copy:#3d5363; --muted:#71818c; --line:#0d78c4; --pale:#edf7fc; --canvas:#f4f5f6; --green:#16815d; --amber:#b66b00; --red:#c5413c; font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif; color:var(--ink); }
|
|
||||||
* { box-sizing:border-box; }
|
|
||||||
body { margin:0; min-width:320px; background:var(--canvas); }
|
|
||||||
.page { min-height:100vh; max-width:1480px; margin:0 auto; background:#fff; box-shadow:0 0 40px rgba(12,38,60,.08); }
|
|
||||||
.wordmark { display:flex; align-items:flex-end; gap:9px; font-size:18px; font-weight:900; letter-spacing:-.03em; }
|
|
||||||
.wordmark::before { content:""; width:24px; height:21px; display:inline-block; background:linear-gradient(155deg,transparent 37%,#0571bc 38% 47%,transparent 48%),linear-gradient(26deg,transparent 42%,#0571bc 43% 52%,transparent 53%); border-left:2px solid #0571bc; border-bottom:2px solid #0571bc; }
|
|
||||||
.report-name { margin:24px 0 31px; font-weight:800; font-size:13px; line-height:1.3; }
|
|
||||||
.report-name span { display:block; color:var(--hmm); }
|
|
||||||
.side-tools { padding:12px 0 22px; border-bottom:1px solid #8f969a; color:#056bad; font-size:16px; letter-spacing:6px; }
|
|
||||||
nav { margin-top:22px; } .nav-group { padding:15px 0; border-bottom:1px solid #8f969a; }
|
|
||||||
.nav-group-title { margin:0 0 11px; font-size:12px; font-weight:850; text-transform:uppercase; }
|
|
||||||
.nav-item { display:block; padding:5px 9px; color:#69747b; font-size:11px; line-height:1.3; text-decoration:none; }
|
|
||||||
.nav-item.active { color:#fff; background:linear-gradient(90deg,#0066ae,#52a9d9); font-weight:800; }
|
|
||||||
.side-foot { margin-top:28px; color:#748087; font-size:10px; line-height:1.6; }
|
|
||||||
.content { padding:42px min(6vw,92px) 45px; }
|
|
||||||
.brand-banner { display:flex; align-items:center; justify-content:space-between; gap:18px; padding-bottom:18px; }
|
|
||||||
.brand-banner img { display:block; width:128px; height:auto; }
|
|
||||||
.brand-banner span { color:#627887; font-size:10px; font-weight:750; letter-spacing:.12em; text-transform:uppercase; }
|
|
||||||
.top-line { height:4px; background:#045fa5; margin-bottom:32px; }
|
|
||||||
.report-head { display:flex; justify-content:space-between; align-items:flex-start; gap:28px; padding-bottom:35px; border-bottom:1px solid var(--line); }
|
|
||||||
.kicker { margin:0 0 10px; color:#0070ba; font-size:12px; font-weight:850; letter-spacing:.025em; }
|
|
||||||
h1 { margin:0; color:#075b9f; font-size:clamp(30px,3.25vw,50px); line-height:1.16; letter-spacing:-.07em; font-weight:700; }
|
|
||||||
h1 b { font-weight:850; }
|
|
||||||
.head-meta { min-width:195px; padding-left:22px; border-left:1px solid #96c8e7; color:#647682; font-size:11px; line-height:1.65; }
|
|
||||||
.head-meta strong { display:block; color:#075b9f; font-size:12px; }
|
|
||||||
.intro { padding:18px 0 23px; color:var(--copy); font-size:15px; line-height:1.75; border-bottom:1px solid #8ac5e8; }
|
|
||||||
.section-heading { margin:0 0 16px; color:#005fa9; font-size:21px; letter-spacing:-.05em; }
|
|
||||||
.section-heading small { margin-left:8px; color:#71818c; font-size:11px; letter-spacing:0; font-weight:500; }
|
|
||||||
.executive { display:grid; grid-template-columns:1.45fr 1fr; gap:42px; padding:29px 0 30px; border-bottom:1px solid var(--line); }
|
|
||||||
.metrics { display:grid; grid-template-columns:repeat(2,1fr); border-top:2px solid #1478bb; border-left:1px solid #c8dce9; }
|
|
||||||
.metric { min-height:111px; padding:17px 18px; border-right:1px solid #c8dce9; border-bottom:1px solid #c8dce9; }
|
|
||||||
.metric-label { color:#5b6f7e; font-size:11px; font-weight:700; }.metric-value { margin:8px 0 5px; color:#075b9f; font-size:27px; letter-spacing:-.055em; font-weight:850; font-variant-numeric:tabular-nums; }.metric-value.money { font-size:23px; }.metric-note { color:#75848c; font-size:10px; }
|
|
||||||
.risk-panel { padding:2px 0 0 25px; border-left:1px dotted #1478bb; }.risk-panel h2 { margin:0 0 15px; color:#005fa9; font-size:20px; letter-spacing:-.05em; }
|
|
||||||
.risk-message { margin:0 0 17px; color:var(--copy); font-size:12px; line-height:1.65; }.risk-list { display:grid; gap:9px; }.risk-item { display:flex; align-items:center; gap:10px; padding-bottom:8px; border-bottom:1px solid #d7e8f3; font-size:11px; }.risk-item:last-child { border-bottom:0; }.risk-item-name { flex:1; font-weight:750; }.pill { padding:3px 7px; border-radius:2px; font-size:10px; font-weight:850; }.pill.red { color:var(--red); background:#fff1f0; }.pill.amber { color:var(--amber); background:#fff6e7; }.pill.green { color:var(--green); background:#eaf7f0; }
|
|
||||||
.portfolio { padding:31px 0 28px; border-bottom:1px solid var(--line); }.portfolio-grid { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(260px,.8fr); gap:43px; }.chart-note { margin:-8px 0 22px; color:#73838e; font-size:11px; }.bar-chart { display:grid; gap:12px; }.bar-row { display:grid; grid-template-columns:146px minmax(100px,1fr) 112px; align-items:center; gap:12px; }.bar-person { font-size:11px; }.bar-person b { display:block; color:#134f7f; font-size:12px; }.bar-person span { color:#788791; }.bar-lane { height:24px; background:#ebf2f6; overflow:hidden; }.bar { height:100%; min-width:4px; background:linear-gradient(90deg,#0073bb,#1aa2d8); position:relative; }.bar.critical { background:linear-gradient(90deg,#0073bb 0 84%,#d75349 84%); }.bar-value { color:#15486f; text-align:right; font-size:11px; font-weight:800; font-variant-numeric:tabular-nums; }.bar-value span { display:block; color:#72828c; font-size:9px; font-weight:500; }
|
|
||||||
.chart-aside { padding:14px 0 0 22px; border-left:1px dotted #1478bb; }.chart-aside h3 { margin:0 0 14px; color:#005fa9; font-size:16px; }.legend { display:flex; justify-content:space-between; align-items:end; padding:10px 0; border-bottom:1px solid #d5e4ed; }.legend:last-child { border-bottom:0; }.legend-label { display:flex; align-items:center; gap:8px; font-size:11px; }.dot { width:10px; height:10px; border-radius:50%; }.dot.green { background:var(--green); }.dot.amber { background:var(--amber); }.dot.red { background:var(--red); }.legend strong { color:#075b9f; font-size:20px; }
|
|
||||||
.detail { padding:31px 0 28px; }.detail-head { display:flex; justify-content:space-between; align-items:end; gap:16px; }.detail-head p { margin:0 0 16px; color:#75848c; font-size:11px; }.detail-table { width:100%; border-collapse:collapse; border-top:2px solid #1478bb; }.detail-table th { padding:11px 10px; background:#eff8fd; color:#176aa4; text-align:left; font-size:10px; font-weight:800; }.detail-table td { padding:12px 10px; border-bottom:1px solid #d4e3eb; color:#334f62; font-size:11px; }.detail-table .code { display:block; margin-bottom:2px; color:#75848c; font-size:9px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.detail-table .name { font-weight:750; }.number { text-align:right; font-variant-numeric:tabular-nums; }.negative { color:var(--red)!important; }.empty { padding:35px; color:#77868f; text-align:center; border:1px solid #d5e4ed; }
|
|
||||||
.footnotes { display:grid; grid-template-columns:1.4fr 1fr; gap:25px; padding-top:19px; border-top:1px solid var(--line); color:#697d89; font-size:10px; line-height:1.7; }.footnotes h3 { margin:0 0 5px; color:#075b9f; font-size:11px; }.footnotes p,.footnotes ul { margin:0; padding-left:15px; }.footnotes p { padding-left:0; }
|
|
||||||
@media(max-width:1000px){.content{padding:32px 35px 42px}.executive,.portfolio-grid{gap:25px}.bar-row{grid-template-columns:118px minmax(80px,1fr) 95px}}
|
|
||||||
@media(max-width:760px){.content{padding:24px 18px 35px}.top-line{margin-bottom:20px}.report-head,.executive,.portfolio-grid,.footnotes{grid-template-columns:1fr;display:grid}.report-head{gap:17px;padding-bottom:24px}.head-meta{padding-left:0;border-left:0;border-top:1px solid #96c8e7;padding-top:10px}.intro{font-size:13px}.risk-panel,.chart-aside{padding:24px 0 0;border-left:0;border-top:1px dotted #1478bb}.bar-row{grid-template-columns:105px minmax(30px,1fr) 80px;gap:7px}.detail{overflow-x:auto}.detail-table{min-width:690px}.footnotes{gap:14px}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main id="report-root" class="page" aria-live="polite"></main>
|
|
||||||
<script>
|
|
||||||
// 서버가 이미 권한 검사를 마친 조회 결과 JSON을 이 위치에 주입한다.
|
|
||||||
const reportData = __REPORT_DATA__;
|
|
||||||
const esc=(v)=>String(v??"").replace(/[&<>'"]/g,c=>({"&":"&","<":"<",">":">","'":"'","\"":"""}[c]));
|
|
||||||
const n=(v,d=2)=>new Intl.NumberFormat("en-US",{maximumFractionDigits:d}).format(Number(v||0));
|
|
||||||
const usd=(v)=>`${n(v)} USD`; const pct=(v)=>`${n(v)}%`; const when=(v)=>new Intl.DateTimeFormat("ko-KR",{dateStyle:"medium",timeStyle:"short",timeZone:"Asia/Seoul"}).format(new Date(v));
|
|
||||||
const riskClass=(v)=>String(v||"").toLowerCase(); const riskLabel=(v)=>({GREEN:"정상",AMBER:"주의",RED:"위험"}[v]||v||"미분류");
|
|
||||||
function aggregate(rows){ const risks={GREEN:0,AMBER:0,RED:0}; const people=new Map(); rows.forEach(row=>{const revenue=Number(row.latestRevenueUsd||0); const margin=Number(row.latestGrossMarginUsd||0); risks[row.latestRiskLevel]=(risks[row.latestRiskLevel]||0)+1; if(!people.has(row.employeeCode)) people.set(row.employeeCode,{code:row.employeeCode,name:row.employeeName,revenue:0,carriers:0,hasRisk:false}); const p=people.get(row.employeeCode);p.revenue+=revenue;p.carriers+=1;p.hasRisk||=row.latestRiskLevel==="RED"||row.latestRiskLevel==="AMBER";}); const totalRevenue=rows.reduce((s,r)=>s+Number(r.latestRevenueUsd||0),0); const totalMargin=rows.reduce((s,r)=>s+Number(r.latestGrossMarginUsd||0),0); return {risks,people:[...people.values()].sort((a,b)=>b.revenue-a.revenue),totalRevenue,totalMargin,avgReliability:rows.length?rows.reduce((s,r)=>s+Number(r.latestScheduleReliabilityPct||0),0)/rows.length:0}; }
|
|
||||||
function badge(value){return `<span class="pill ${riskClass(value)}">${esc(riskLabel(value))}</span>`;}
|
|
||||||
function detailRow(row){return `<tr><td><span class="code">${esc(row.employeeCode)}</span><span class="name">${esc(row.employeeName)}</span></td><td><span class="code">${esc(row.carrierCode)}</span><span class="name">${esc(row.carrierName)}</span></td><td class="number">${usd(row.latestRevenueUsd)}</td><td class="number ${Number(row.latestGrossMarginUsd)<0?"negative":""}">${usd(row.latestGrossMarginUsd)}</td><td class="number">${pct(row.latestScheduleReliabilityPct)}</td><td>${badge(row.latestRiskLevel)}</td></tr>`;}
|
|
||||||
function renderHmmCarrierPerformanceReport(payload,target=document.getElementById("report-root")){const report=payload?.report||{};const rows=Array.isArray(payload?.rows)?payload.rows:[];const s=aggregate(rows);const max=Math.max(...s.people.map(p=>p.revenue),1);const watch=rows.filter(r=>r.latestRiskLevel!=="GREEN").sort((a,b)=>a.latestRiskLevel.localeCompare(b.latestRiskLevel));target.innerHTML=`
|
|
||||||
<section class="content"><div class="brand-banner"><img src="https://eu-images.contentstack.com/v3/assets/bltdcfe6aab5515629e/bltaf50776e73f3149f/668ea7b97dc26754645e1830/hmmci.png?width=1400&auto=webp&quality=80&disable=upscale" alt="HMM"><span>HMM Management Report · Internal Demo</span></div><div class="top-line"></div><header class="report-head"><div><p class="kicker">${esc(report.id||"FEDERATION")} · ${esc(report.category||"HMM Business Intelligence")}</p><h1><b>${esc(report.title||"팀 포트폴리오")}</b> — 최신 선사 실적</h1></div><div class="head-meta"><strong>${esc(report.requestedBy||"-")} 팀장 조회</strong>생성 ${esc(when(report.generatedAt))}<br>MCP ${esc(report.execution?.calls||0)}회 호출<br>${esc(report.execution?.tool||"-")}</div></header>
|
|
||||||
<p class="intro">${esc(report.answer||report.question||"조회된 선사 실적입니다.")} 전체 ${n(rows.length,0)}개 담당 선사의 매출·수익성·운항 지표를 담당자별 포트폴리오 관점에서 요약했습니다.</p>
|
|
||||||
<section id="summary" class="executive"><div><h2 class="section-heading">핵심 요약 <small>Latest performance snapshot</small></h2><div class="metrics"><article class="metric"><div class="metric-label">담당 선사</div><div class="metric-value">${n(rows.length,0)}<small>개</small></div><div class="metric-note">팀원 ${n(s.people.length,0)}명 기준</div></article><article class="metric"><div class="metric-label">최신 매출 합계</div><div class="metric-value money">${usd(s.totalRevenue)}</div><div class="metric-note">담당 선사별 최신 기준월 합산</div></article><article class="metric"><div class="metric-label">매출총이익 합계</div><div class="metric-value money ${s.totalMargin<0?"negative":""}">${usd(s.totalMargin)}</div><div class="metric-note">음수 마진 선사 포함</div></article><article class="metric"><div class="metric-label">평균 정시 운항률</div><div class="metric-value">${pct(s.avgReliability)}</div><div class="metric-note">담당 선사 단순 평균</div></article></div></div><aside id="risk" class="risk-panel"><h2>위험 신호</h2><p class="risk-message">주의·위험 등급 선사 ${n(s.risks.AMBER+s.risks.RED,0)}개를 우선 점검 대상으로 표시합니다.</p><div class="risk-list">${watch.length?watch.map(r=>`<div class="risk-item">${badge(r.latestRiskLevel)}<span class="risk-item-name">${esc(r.carrierName)}</span><span>${esc(r.employeeName)}</span></div>`).join(""):'<div class="risk-item">현재 주의·위험 선사가 없습니다.</div>'}</div></aside></section>
|
|
||||||
<section id="portfolio" class="portfolio"><h2 class="section-heading">담당자별 포트폴리오 매출 <small>Latest revenue by employee</small></h2><div class="portfolio-grid"><div><p class="chart-note">각 막대는 담당 선사 최신 매출의 합계입니다. 막대 끝의 색상은 해당 담당자 포트폴리오에 주의·위험 선사가 있는 경우를 나타냅니다.</p><div class="bar-chart">${s.people.map(p=>`<div class="bar-row"><div class="bar-person"><b>${esc(p.name)}</b><span>${esc(p.code)} · ${n(p.carriers,0)}개 선사</span></div><div class="bar-lane"><div class="bar ${p.hasRisk?"critical":""}" style="width:${(p.revenue/max*100).toFixed(2)}%"></div></div><div class="bar-value">${usd(p.revenue)}<span>팀 매출 ${(p.revenue/s.totalRevenue*100).toFixed(1)}%</span></div></div>`).join("")||'<div class="empty">차트 데이터가 없습니다.</div>'}</div></div><aside class="chart-aside"><h3>위험 등급 분포</h3><div class="legend"><span class="legend-label"><i class="dot green"></i>정상</span><strong>${n(s.risks.GREEN,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot amber"></i>주의</span><strong>${n(s.risks.AMBER,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot red"></i>위험</span><strong>${n(s.risks.RED,0)}</strong></div></aside></div></section>
|
|
||||||
<section id="detail" class="detail"><div class="detail-head"><h2 class="section-heading">선사별 최신 지표 <small>Carrier detail</small></h2><p>총 ${n(rows.length,0)}건 · 최신순 1~${n(rows.length,0)}건 표시</p></div>${rows.length?`<table class="detail-table"><thead><tr><th>담당자</th><th>선사</th><th class="number">최신 매출</th><th class="number">매출총이익</th><th class="number">정시 운항률</th><th>위험 등급</th></tr></thead><tbody>${rows.map(detailRow).join("")}</tbody></table>`:'<div class="empty">표시할 상세 데이터가 없습니다.</div>'}</section>
|
|
||||||
<footer id="notes" class="footnotes"><section><h3>답변 근거</h3><ul>${(report.evidence||[]).map(e=>`<li>${esc(e)}</li>`).join("")||'<li>제공된 근거가 없습니다.</li>'}</ul></section><section><h3>제약 및 주의</h3><p>${esc(report.limitation||"제약 정보가 제공되지 않았습니다.")}</p></section></footer></section>`;}
|
|
||||||
window.renderHmmCarrierPerformanceReport=renderHmmCarrierPerformanceReport;renderHmmCarrierPerformanceReport(reportData);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"description": "HMM AI 업무 에이전트 화면에 표시할 데모 질문 목록입니다. 이 파일을 수정하면 재배포 후 질문 메뉴에 반영됩니다.",
|
|
||||||
"scenarios": [
|
|
||||||
{
|
|
||||||
"id": "HR-01",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "휴가 규정",
|
|
||||||
"title": "연차 이월 기준",
|
|
||||||
"question": "연차 휴가 이월 기준과 제한을 알려줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "HR-02",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "휴가 규정",
|
|
||||||
"title": "입사일 기준 연차",
|
|
||||||
"question": "입사일 기준으로 연차가 언제 발생하는지 알려줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "HR-03",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "근태 용어",
|
|
||||||
"title": "휴가 명칭 표준화",
|
|
||||||
"question": "반차와 반일 휴가의 표준 근태 용어를 알려줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "HR-04",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "조직·인력",
|
|
||||||
"title": "팀 구성 조회",
|
|
||||||
"question": "팀별 인원과 매니저를 보여줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "HR-05",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "근태 현황",
|
|
||||||
"title": "휴가 현황 조회",
|
|
||||||
"question": "이번 달 팀원별 휴가 사용 현황을 보여줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FED-01",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "선사 실적",
|
|
||||||
"title": "우리 팀 담당 선사 최신 실적",
|
|
||||||
"question": "우리 팀원별 담당 선사와 각 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FED-02",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "선사 실적",
|
|
||||||
"title": "주의가 필요한 선사",
|
|
||||||
"question": "우리 팀이 담당하는 선사 중 최신 위험 등급이 RED인 선사의 담당자, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FED-03",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "선사 실적",
|
|
||||||
"title": "내 담당 선사 월별 추이",
|
|
||||||
"question": "내가 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FED-04",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "선사 실적 리포트",
|
|
||||||
"title": "우리 팀 선사 실적 리포트",
|
|
||||||
"question": "우리 팀원별 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 HMM 리포트로 보여줘"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FED-05",
|
|
||||||
"enabled": true,
|
|
||||||
"category": "선사 실적 리포트",
|
|
||||||
"title": "내 담당 선사 실적 리포트",
|
|
||||||
"question": "내 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 HMM 리포트로 보여줘"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,24 +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-backoffice.cloud-handson.com/mcp",
|
|
||||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
|
||||||
"timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS",
|
|
||||||
"default_tool": "search_hr_data",
|
|
||||||
"router_model_profile": "gpt54_mini_oci",
|
|
||||||
"tool_allowlist": [
|
|
||||||
"search_hr_data",
|
|
||||||
"resolve_hr_term",
|
|
||||||
"search_hr_policy",
|
|
||||||
"search_carrier_performance",
|
|
||||||
"render_hmm_carrier_report"
|
|
||||||
],
|
|
||||||
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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_E1001",
|
|
||||||
"user_id": "E1001",
|
|
||||||
"name": "Kim Minseo",
|
|
||||||
"role": "HR Team Manager",
|
|
||||||
"team": "HMM HR Demo Team",
|
|
||||||
"scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1002",
|
|
||||||
"user_id": "E1002",
|
|
||||||
"name": "Lee Jiwon",
|
|
||||||
"role": "HR Operations Specialist",
|
|
||||||
"team": "HMM HR Demo Team",
|
|
||||||
"scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1003",
|
|
||||||
"user_id": "E1003",
|
|
||||||
"name": "Park Dohyun",
|
|
||||||
"role": "People Analytics Analyst",
|
|
||||||
"team": "HMM HR Demo Team",
|
|
||||||
"scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1005",
|
|
||||||
"user_id": "E1005",
|
|
||||||
"name": "Han Seojun",
|
|
||||||
"role": "Recruiting Specialist",
|
|
||||||
"team": "HMM HR Demo Team",
|
|
||||||
"scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1007",
|
|
||||||
"user_id": "E1007",
|
|
||||||
"name": "Kang Minho",
|
|
||||||
"role": "HR Coordinator",
|
|
||||||
"team": "HMM HR Demo Team",
|
|
||||||
"scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any, Mapping
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios
|
|
||||||
from ai_web_agent_console.profile import load_app_profile
|
|
||||||
from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments
|
|
||||||
from ai_web_agent_console.mcp_result import (
|
|
||||||
has_actionable_text_result,
|
|
||||||
status_result_evidence,
|
|
||||||
status_result_summary,
|
|
||||||
)
|
|
||||||
from ai_web_agent_console.model_registry import load_model_registry
|
|
||||||
|
|
||||||
|
|
||||||
def _load_console_query_helpers():
|
|
||||||
"""Load the Streamlit entrypoint only when its optional runtime is installed."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app import _prepare_hmm_hr_tool_query
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
return None
|
|
||||||
return _prepare_hmm_hr_tool_query
|
|
||||||
|
|
||||||
|
|
||||||
class DemoScenarioConfigTest(unittest.TestCase):
|
|
||||||
def test_model_registry_uses_console_names(self) -> None:
|
|
||||||
registry = load_model_registry()
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
registry.registry_name,
|
|
||||||
"AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
|
||||||
)
|
|
||||||
self.assertTrue(registry.default_profile.default_for_console)
|
|
||||||
|
|
||||||
def test_profile_environment_overrides_json_defaults(self) -> None:
|
|
||||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
|
||||||
with patch.dict(
|
|
||||||
"os.environ",
|
|
||||||
{
|
|
||||||
"AGENT_CONSOLE_SHORT_NAME": "HMM",
|
|
||||||
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
|
|
||||||
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
|
|
||||||
},
|
|
||||||
clear=False,
|
|
||||||
):
|
|
||||||
profile = load_app_profile(path)
|
|
||||||
|
|
||||||
self.assertEqual(profile.short_name, "HMM")
|
|
||||||
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
|
|
||||||
self.assertEqual(profile.primary_color, "#003b70")
|
|
||||||
|
|
||||||
def test_profile_reads_dotenv_values(self) -> None:
|
|
||||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
|
||||||
env_file = Path(temp_dir) / ".env"
|
|
||||||
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
|
|
||||||
profile = load_app_profile(path, env_file)
|
|
||||||
|
|
||||||
self.assertEqual(profile.short_name, "HMM")
|
|
||||||
|
|
||||||
def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None:
|
|
||||||
path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py"
|
|
||||||
source = path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
self.assertIn('[data-testid="stAppViewContainer"] li', source)
|
|
||||||
self.assertIn('[data-testid="stExpander"] summary', source)
|
|
||||||
self.assertIn('div[data-testid="stButton"] > button', source)
|
|
||||||
self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source)
|
|
||||||
self.assertIn('[data-testid="stTab"]', source)
|
|
||||||
self.assertIn('[role="tab"][aria-selected="true"]', source)
|
|
||||||
|
|
||||||
def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None:
|
|
||||||
root = Path(__file__).parents[1]
|
|
||||||
entrypoint = (root / "app.py").read_text(
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
renderer = (root / "ai_web_agent_console" / "audit.py").read_text(
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint)
|
|
||||||
self.assertIn("_load_hmm_audit_inventory", entrypoint)
|
|
||||||
self.assertIn("(protocol=tcps)(port=1521)", entrypoint)
|
|
||||||
self.assertIn(
|
|
||||||
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD",
|
|
||||||
entrypoint,
|
|
||||||
)
|
|
||||||
self.assertIn("HMM 접근 관리", renderer)
|
|
||||||
self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint)
|
|
||||||
|
|
||||||
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
|
||||||
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
|
||||||
scenarios = load_demo_scenarios(path)
|
|
||||||
|
|
||||||
self.assertGreaterEqual(len(scenarios), 3)
|
|
||||||
self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios}))
|
|
||||||
self.assertTrue(all(item.question.strip() for item in scenarios))
|
|
||||||
by_id = {item.scenario_id: item for item in scenarios}
|
|
||||||
self.assertEqual(
|
|
||||||
{"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"},
|
|
||||||
{"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"} & set(by_id),
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
all("선사" in by_id[scenario_id].question for scenario_id in (
|
|
||||||
"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"
|
|
||||||
))
|
|
||||||
)
|
|
||||||
carrier_scenarios = [
|
|
||||||
item for item in scenarios if item.scenario_id.startswith("FED-")
|
|
||||||
]
|
|
||||||
self.assertTrue(
|
|
||||||
all(
|
|
||||||
re.search(r"\bE\d{4,}\b", item.question, flags=re.IGNORECASE) is None
|
|
||||||
for item in carrier_scenarios
|
|
||||||
)
|
|
||||||
)
|
|
||||||
report_scenarios = [
|
|
||||||
item for item in carrier_scenarios if "리포트로 보여줘" in item.question
|
|
||||||
]
|
|
||||||
self.assertGreaterEqual(len(report_scenarios), 2)
|
|
||||||
self.assertTrue(
|
|
||||||
all(item.category == "선사 실적 리포트" for item in report_scenarios)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hmm_mcp_allows_carrier_federation_tool(self) -> None:
|
|
||||||
path = Path(__file__).parents[1] / "config" / "mcp_servers.json"
|
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
server = next(
|
|
||||||
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn(
|
|
||||||
"search_carrier_performance",
|
|
||||||
server["tool_allowlist"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hmm_mcp_allows_dynamic_html_renderer(self) -> None:
|
|
||||||
root = Path(__file__).parents[1]
|
|
||||||
payload = json.loads(
|
|
||||||
(root / "config" / "mcp_servers.json").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
server = next(
|
|
||||||
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
|
||||||
)
|
|
||||||
source = (root / "app.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
server["endpoint_url"],
|
|
||||||
"https://hmm-backoffice.cloud-handson.com/mcp",
|
|
||||||
)
|
|
||||||
self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
|
|
||||||
self.assertNotIn('tool.name == "render_hmm_carrier_report"', source)
|
|
||||||
|
|
||||||
def test_hmm_report_rows_accept_nested_select_ai_json_array(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helper = next(
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name == "_mcp_structured_rows"
|
|
||||||
)
|
|
||||||
namespace: dict[str, Any] = {
|
|
||||||
"Any": Any,
|
|
||||||
"Mapping": Mapping,
|
|
||||||
"json": json,
|
|
||||||
}
|
|
||||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
rows = namespace["_mcp_structured_rows"](
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"result": json.dumps(
|
|
||||||
[
|
|
||||||
{"EMPLOYEE_CODE": "E9001", "CARRIER_CODE": "C901"},
|
|
||||||
{"EMPLOYEE_CODE": "E9002", "CARRIER_CODE": "C902"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(len(rows), 2)
|
|
||||||
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
|
|
||||||
|
|
||||||
def test_hmm_report_normalizes_repeated_select_ai_column_labels(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helpers = [
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name in {"_camel_case_key", "_normalize_presentation_value"}
|
|
||||||
]
|
|
||||||
namespace: dict[str, Any] = {
|
|
||||||
"Any": Any,
|
|
||||||
"Mapping": Mapping,
|
|
||||||
"re": re,
|
|
||||||
}
|
|
||||||
exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
first = namespace["_normalize_presentation_value"](
|
|
||||||
{"CARRIER_CODE": "C001", "LATEST_REVENUE_USD": 100}
|
|
||||||
)
|
|
||||||
repeated = namespace["_normalize_presentation_value"](
|
|
||||||
{"carrier Code": "C002", "LATEST REVENUE USD": 200}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(first, {"carrierCode": "C001", "latestRevenueUsd": 100})
|
|
||||||
self.assertEqual(repeated, {"carrierCode": "C002", "latestRevenueUsd": 200})
|
|
||||||
|
|
||||||
def test_hmm_report_title_and_answer_follow_presentation_contract(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helper = next(
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name == "_clean_presentation_title"
|
|
||||||
)
|
|
||||||
namespace: dict[str, Any] = {
|
|
||||||
"Any": Any,
|
|
||||||
"html": html,
|
|
||||||
"re": re,
|
|
||||||
}
|
|
||||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
title = namespace["_clean_presentation_title"](
|
|
||||||
"<b>E1001 팀 포트폴리오</b>"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(title, "E1001 팀 포트폴리오")
|
|
||||||
self.assertIn('"title": _clean_presentation_title(title)', source)
|
|
||||||
self.assertIn(
|
|
||||||
'assistant_message["content"] = presentation_answer',
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"presentation_answer = _presentation_completion_answer(",
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"do not reproduce HTML tags, Markdown tables",
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_chat_context_is_scoped_to_current_selected_user(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helper = next(
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name == "load_chat_context"
|
|
||||||
)
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class FakeConnection:
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *_args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def execute(self, sql, params):
|
|
||||||
captured["sql"] = sql
|
|
||||||
captured["params"] = params
|
|
||||||
return self
|
|
||||||
|
|
||||||
def fetchall(self):
|
|
||||||
return [{"question": "내 담당 선사", "answer": "2건"}]
|
|
||||||
|
|
||||||
namespace: dict[str, Any] = {
|
|
||||||
"CHAT_CONTEXT_TURNS": 8,
|
|
||||||
"MAX_CONVERSATION_MESSAGES": 16,
|
|
||||||
"_chat_db_connect": FakeConnection,
|
|
||||||
"_is_failed_synthesis_answer": lambda _value: False,
|
|
||||||
}
|
|
||||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
messages = namespace["load_chat_context"](
|
|
||||||
"conversation-1",
|
|
||||||
selected_user_id="E1002",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn("selected_user_id = ?", captured["sql"])
|
|
||||||
self.assertEqual(
|
|
||||||
captured["params"],
|
|
||||||
("conversation-1", "E1002", "E1002", 8),
|
|
||||||
)
|
|
||||||
self.assertEqual(messages[0]["content"], "내 담당 선사")
|
|
||||||
|
|
||||||
def test_standalone_question_uses_current_user_without_prior_context(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helpers = [
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name in {"_conversation_context", "resolve_standalone_question"}
|
|
||||||
]
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
def complete(self, **kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"standalone_question": (
|
|
||||||
"E1002 사용자의 담당 선사 최신 실적을 조회해줘"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
namespace: dict[str, Any] = {
|
|
||||||
"Any": Any,
|
|
||||||
"Mapping": Mapping,
|
|
||||||
"MAX_CONVERSATION_MESSAGES": 16,
|
|
||||||
"json": json,
|
|
||||||
"resolve_model_profile": lambda _key: SimpleNamespace(
|
|
||||||
model_id="model",
|
|
||||||
answer_model_region="region",
|
|
||||||
answer_model_endpoint="endpoint",
|
|
||||||
),
|
|
||||||
"build_oci_genai_completion_client": lambda *_args: FakeClient(),
|
|
||||||
"temperature_for_model_profile": lambda _profile: 0.0,
|
|
||||||
}
|
|
||||||
exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
rewritten = namespace["resolve_standalone_question"](
|
|
||||||
question="내 담당 선사 최신 실적을 리포트로 보여줘",
|
|
||||||
messages=[],
|
|
||||||
model_profile_key="test",
|
|
||||||
selected_user_id="E1002",
|
|
||||||
)
|
|
||||||
prompt_payload = json.loads(captured["user_prompt"])
|
|
||||||
|
|
||||||
self.assertTrue(rewritten.startswith("E1002"))
|
|
||||||
self.assertEqual(prompt_payload["current_selected_user_id"], "E1002")
|
|
||||||
self.assertIn("authoritative", captured["system_prompt"])
|
|
||||||
|
|
||||||
def test_report_payload_requester_prefers_current_selected_user(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helper = next(
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name == "_presentation_payload"
|
|
||||||
)
|
|
||||||
namespace: dict[str, Any] = {
|
|
||||||
"Any": Any,
|
|
||||||
"Mapping": Mapping,
|
|
||||||
"datetime": __import__("datetime").datetime,
|
|
||||||
"timezone": __import__("datetime").timezone,
|
|
||||||
"re": re,
|
|
||||||
"_mcp_structured_rows": lambda _value: [],
|
|
||||||
"_normalize_presentation_value": lambda value: value,
|
|
||||||
"_clean_presentation_title": lambda value: str(value),
|
|
||||||
}
|
|
||||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
payload = namespace["_presentation_payload"](
|
|
||||||
"E1001 팀장 문맥이 남은 질문",
|
|
||||||
[],
|
|
||||||
title="담당 선사 실적",
|
|
||||||
selected_user_id="E1002",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(payload["report"]["requestedBy"], "E1002")
|
|
||||||
|
|
||||||
def test_hmm_report_data_query_drops_html_format_request(self) -> None:
|
|
||||||
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
helper = next(
|
|
||||||
node
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.FunctionDef)
|
|
||||||
and node.name == "_fallback_presentation_data_query"
|
|
||||||
)
|
|
||||||
namespace: dict[str, Any] = {"re": re}
|
|
||||||
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
|
||||||
|
|
||||||
query = namespace["_fallback_presentation_data_query"](
|
|
||||||
"E1001 팀장의 담당 선사 최신 매출, 매출총이익, 정시 운항률, "
|
|
||||||
"위험 등급을 HTML로 보여줘"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertNotIn("HTML", query.upper())
|
|
||||||
self.assertIn("E1001", query)
|
|
||||||
self.assertIn("매출총이익", query)
|
|
||||||
self.assertIn("정시 운항률", query)
|
|
||||||
self.assertIn("위험 등급", query)
|
|
||||||
self.assertIn("_mcp_rows_contain_presentation_markup(mcp_result)", source)
|
|
||||||
|
|
||||||
personal_query = namespace["_fallback_presentation_data_query"](
|
|
||||||
"내 담당 선사와 최신 매출을 리포트로 보여줘"
|
|
||||||
)
|
|
||||||
self.assertEqual(personal_query, "내 담당 선사와 최신 매출을 보여줘")
|
|
||||||
|
|
||||||
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
|
|
||||||
template = (
|
|
||||||
Path(__file__).parents[1]
|
|
||||||
/ "assets"
|
|
||||||
/ "hmm-carrier-performance-report.html"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
self.assertEqual(template.count("__REPORT_DATA__"), 1)
|
|
||||||
self.assertNotIn("Bluewave Maritime", template)
|
|
||||||
self.assertNotIn("Southern Cross Marine", template)
|
|
||||||
|
|
||||||
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
|
|
||||||
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
|
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
presets = payload["presets"]
|
|
||||||
|
|
||||||
self.assertEqual(payload["version"], 2)
|
|
||||||
self.assertEqual({item["user_id"] for item in presets}, {
|
|
||||||
"E1001", "E1002", "E1003", "E1005", "E1007"
|
|
||||||
})
|
|
||||||
self.assertEqual(
|
|
||||||
{item["mcp_token_env"] for item in presets},
|
|
||||||
{f"HMM_MCP_BEARER_TOKEN_{item['user_id']}" for item in presets},
|
|
||||||
)
|
|
||||||
self.assertTrue(all("token" not in item for item in presets))
|
|
||||||
|
|
||||||
def test_duplicate_id_is_rejected(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
|
||||||
path = Path(temp_dir) / "scenarios.json"
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"scenarios": [
|
|
||||||
{"id": "HR-01", "title": "one", "question": "q1"},
|
|
||||||
{"id": "HR-01", "title": "two", "question": "q2"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
with self.assertRaises(ScenarioConfigError):
|
|
||||||
load_demo_scenarios(path)
|
|
||||||
|
|
||||||
def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None:
|
|
||||||
tool = McpTool(
|
|
||||||
name="search_hr_data",
|
|
||||||
description="",
|
|
||||||
schema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"query": {"type": "string"}},
|
|
||||||
"required": ["query"],
|
|
||||||
},
|
|
||||||
read_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
arguments = build_mcp_tool_arguments(
|
|
||||||
tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"})
|
|
||||||
|
|
||||||
def test_term_tool_arguments_follow_discovered_term_schema(self) -> None:
|
|
||||||
tool = McpTool(
|
|
||||||
name="resolve_hr_term",
|
|
||||||
description="",
|
|
||||||
schema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"term": {"type": "string"}},
|
|
||||||
"required": ["term"],
|
|
||||||
},
|
|
||||||
read_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
arguments = build_mcp_tool_arguments(
|
|
||||||
tool, "반차", 50, preferred_tool="search_hr_data"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(arguments, {"term": "반차"})
|
|
||||||
|
|
||||||
def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None:
|
|
||||||
result = {
|
|
||||||
"status": "success",
|
|
||||||
"result": (
|
|
||||||
"HR_POLICY_SEARCH_RESULT\n"
|
|
||||||
"EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
summary = status_result_summary(result, excerpt_chars=40)
|
|
||||||
evidence = status_result_evidence(result)
|
|
||||||
|
|
||||||
self.assertEqual(summary["status"], "success")
|
|
||||||
self.assertGreater(summary["result_chars"], 40)
|
|
||||||
self.assertIn("KR_Leave_Policy.pdf", evidence["result"])
|
|
||||||
self.assertTrue(has_actionable_text_result(result))
|
|
||||||
|
|
||||||
def test_no_data_text_is_not_actionable(self) -> None:
|
|
||||||
self.assertFalse(
|
|
||||||
has_actionable_text_result({"status": "success", "result": "No data found"})
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional")
|
|
||||||
def test_policy_query_does_not_include_demo_user_context(self) -> None:
|
|
||||||
prepare = _load_console_query_helpers()
|
|
||||||
assert prepare is not None
|
|
||||||
tool = McpTool(
|
|
||||||
name="search_hr_policy",
|
|
||||||
description="Search policy documents",
|
|
||||||
schema={"properties": {"query": {"type": "string"}}},
|
|
||||||
read_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
query = prepare(
|
|
||||||
question="연차 휴가 이월 기준과 제한을 알려줘",
|
|
||||||
tool=tool,
|
|
||||||
model_profile_key="gpt54_mini_oci",
|
|
||||||
selected_user_id="E1001",
|
|
||||||
selected_user_role="HR Team Manager",
|
|
||||||
selected_user_team="HMM HR Demo Team",
|
|
||||||
selected_user_scope="팀원 6명 관리",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- 47_dds_ad_identity_test_setup.sql
|
|
||||||
--
|
|
||||||
-- Test-only bridge for the isolated dds.test Active Directory domain.
|
|
||||||
-- Maps immutable AD objectGUID values to the existing HMM application users
|
|
||||||
-- 1 and 2, then publishes only their passwordless local DDS END USERs.
|
|
||||||
--
|
|
||||||
-- This does NOT validate an AD/OIDC JWT. A verified issuer + subject must be
|
|
||||||
-- resolved by the MCP application before it uses this bridge.
|
|
||||||
-- ============================================================
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
SET ECHO OFF
|
|
||||||
SET FEEDBACK ON
|
|
||||||
SET DEFINE OFF
|
|
||||||
|
|
||||||
PROMPT === 1. Creating external identity bridge ===
|
|
||||||
BEGIN
|
|
||||||
EXECUTE IMMEDIATE q'[
|
|
||||||
CREATE TABLE cb_external_identity_binding (
|
|
||||||
issuer VARCHAR2(512) NOT NULL,
|
|
||||||
subject VARCHAR2(512) NOT NULL,
|
|
||||||
application_user_id NUMBER NOT NULL,
|
|
||||||
display_name VARCHAR2(256),
|
|
||||||
active CHAR(1) DEFAULT 'Y' NOT NULL
|
|
||||||
CHECK (active IN ('Y', 'N')),
|
|
||||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
|
||||||
CONSTRAINT cb_external_identity_binding_pk PRIMARY KEY (issuer, subject)
|
|
||||||
)]';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
EXECUTE IMMEDIATE 'CREATE INDEX cb_external_identity_binding_user_ix '
|
|
||||||
|| 'ON cb_external_identity_binding (application_user_id)';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
PROMPT === 2. Creating DDS END USER map ===
|
|
||||||
BEGIN
|
|
||||||
EXECUTE IMMEDIATE q'[
|
|
||||||
CREATE TABLE cb_dds_end_user_map (
|
|
||||||
application_user_id NUMBER PRIMARY KEY,
|
|
||||||
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
|
|
||||||
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
|
|
||||||
lookup_key_ref VARCHAR2(128) NOT NULL,
|
|
||||||
grant_name VARCHAR2(128) NOT NULL,
|
|
||||||
publish_status VARCHAR2(20) NOT NULL
|
|
||||||
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
|
|
||||||
published_at TIMESTAMP,
|
|
||||||
last_error VARCHAR2(1000)
|
|
||||||
)]';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE <> -955 THEN RAISE; END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_active_users NUMBER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*) INTO v_active_users
|
|
||||||
FROM cb_app_user
|
|
||||||
WHERE user_id IN (1, 2) AND active = 'Y';
|
|
||||||
IF v_active_users <> 2 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20947, 'Expected active HMM application users 1 and 2.');
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
PROMPT === 3. Binding dds.test immutable subjects to HMM users ===
|
|
||||||
MERGE INTO cb_external_identity_binding target
|
|
||||||
USING (
|
|
||||||
SELECT 'urn:dds-ad:test' AS issuer,
|
|
||||||
'fa7ebe4b-aca8-4049-a436-7f3da2d27a9f' AS subject,
|
|
||||||
1 AS application_user_id,
|
|
||||||
'dds-alice' AS display_name
|
|
||||||
FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'urn:dds-ad:test',
|
|
||||||
'128546cf-ec0a-4e99-8f51-dfce29280c91',
|
|
||||||
2,
|
|
||||||
'dds-bob'
|
|
||||||
FROM dual
|
|
||||||
) source
|
|
||||||
ON (target.issuer = source.issuer AND target.subject = source.subject)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
target.application_user_id = source.application_user_id,
|
|
||||||
target.display_name = source.display_name,
|
|
||||||
target.active = 'Y',
|
|
||||||
target.updated_at = SYSTIMESTAMP
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
issuer, subject, application_user_id, display_name, active
|
|
||||||
) VALUES (
|
|
||||||
source.issuer, source.subject, source.application_user_id, source.display_name, 'Y'
|
|
||||||
);
|
|
||||||
|
|
||||||
MERGE INTO cb_dds_end_user_map target
|
|
||||||
USING (
|
|
||||||
SELECT user_id AS application_user_id,
|
|
||||||
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
|
|
||||||
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
|
|
||||||
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
|
|
||||||
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
|
|
||||||
FROM cb_app_user
|
|
||||||
WHERE user_id IN (1, 2)
|
|
||||||
) source
|
|
||||||
ON (target.application_user_id = source.application_user_id)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
target.end_user_name = source.end_user_name,
|
|
||||||
target.data_role_name = source.data_role_name,
|
|
||||||
target.lookup_key_ref = source.lookup_key_ref,
|
|
||||||
target.grant_name = source.grant_name,
|
|
||||||
target.publish_status = 'PENDING',
|
|
||||||
target.last_error = NULL
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
application_user_id, end_user_name, data_role_name, lookup_key_ref,
|
|
||||||
grant_name, publish_status
|
|
||||||
) VALUES (
|
|
||||||
source.application_user_id, source.end_user_name, source.data_role_name,
|
|
||||||
source.lookup_key_ref, source.grant_name, 'PENDING'
|
|
||||||
);
|
|
||||||
|
|
||||||
PROMPT === 4. Publishing DDS identities only (default deny until data grants exist) ===
|
|
||||||
BEGIN
|
|
||||||
FOR mapped_user IN (
|
|
||||||
SELECT application_user_id, end_user_name, data_role_name
|
|
||||||
FROM cb_dds_end_user_map
|
|
||||||
WHERE application_user_id IN (1, 2)
|
|
||||||
ORDER BY application_user_id
|
|
||||||
) LOOP
|
|
||||||
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
|
|
||||||
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
|
|
||||||
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|
|
||||||
|| ' TO "' || mapped_user.end_user_name || '"';
|
|
||||||
UPDATE cb_dds_end_user_map
|
|
||||||
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
|
|
||||||
WHERE application_user_id = mapped_user.application_user_id;
|
|
||||||
END LOOP;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
PROMPT === 5. Identity bridge inventory ===
|
|
||||||
SELECT b.issuer, b.subject, b.display_name, b.application_user_id,
|
|
||||||
m.end_user_name, m.data_role_name, m.publish_status
|
|
||||||
FROM cb_external_identity_binding b
|
|
||||||
JOIN cb_dds_end_user_map m ON m.application_user_id = b.application_user_id
|
|
||||||
WHERE b.issuer = 'urn:dds-ad:test'
|
|
||||||
ORDER BY b.application_user_id;
|
|
||||||
|
|
||||||
PROMPT === AD identity to DDS END USER test setup complete ===
|
|
||||||
EXIT;
|
|
||||||
@@ -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 ===
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
@@ -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";
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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'
|
|
||||||
);
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
-- HMM carrier report template storage and DBMS_CLOUD_AI_AGENT custom tool.
|
|
||||||
-- Run as ADMIN. Load the approved HTML template into HMM_REPORT_TEMPLATES
|
|
||||||
-- through the deployment loader before enabling the MCP tool.
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE ROLLBACK
|
|
||||||
SET DEFINE OFF
|
|
||||||
|
|
||||||
CREATE TABLE hmm_report_templates (
|
|
||||||
template_key VARCHAR2(64) PRIMARY KEY,
|
|
||||||
template_version VARCHAR2(32) NOT NULL,
|
|
||||||
html_template CLOB NOT NULL,
|
|
||||||
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
|
||||||
CONSTRAINT hmm_report_templates_active_ck CHECK (active_yn IN ('Y', 'N'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE OR REPLACE PACKAGE hmm_report_render_pkg AUTHID DEFINER AS
|
|
||||||
FUNCTION render_carrier_report(p_report_json IN CLOB) RETURN CLOB;
|
|
||||||
END hmm_report_render_pkg;
|
|
||||||
/
|
|
||||||
|
|
||||||
CREATE OR REPLACE PACKAGE BODY hmm_report_render_pkg AS
|
|
||||||
FUNCTION render_carrier_report(p_report_json IN CLOB) RETURN CLOB IS
|
|
||||||
l_template CLOB;
|
|
||||||
l_data CLOB;
|
|
||||||
l_html VARCHAR2(32767);
|
|
||||||
l_result CLOB;
|
|
||||||
BEGIN
|
|
||||||
IF p_report_json IS NULL OR dbms_lob.getlength(p_report_json) > 64000 THEN
|
|
||||||
raise_application_error(-20101, 'Invalid report payload size.');
|
|
||||||
END IF;
|
|
||||||
IF NOT json_exists(p_report_json, '$.report') OR NOT json_exists(p_report_json, '$.rows') THEN
|
|
||||||
raise_application_error(-20102, 'Report payload requires report and rows.');
|
|
||||||
END IF;
|
|
||||||
SELECT html_template INTO l_template
|
|
||||||
FROM hmm_report_templates
|
|
||||||
WHERE template_key = 'hmm-carrier-performance'
|
|
||||||
AND active_yn = 'Y';
|
|
||||||
l_data := replace(p_report_json, '</', '<\/');
|
|
||||||
l_html := dbms_lob.substr(replace(l_template, '__REPORT_DATA__', l_data), 32767, 1);
|
|
||||||
SELECT json_object(
|
|
||||||
'status' VALUE 'ok',
|
|
||||||
'template' VALUE 'hmm-carrier-performance',
|
|
||||||
'html' VALUE l_html
|
|
||||||
RETURNING CLOB
|
|
||||||
) INTO l_result FROM dual;
|
|
||||||
RETURN l_result;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN no_data_found THEN
|
|
||||||
raise_application_error(-20103, 'Active report template is not installed.');
|
|
||||||
END render_carrier_report;
|
|
||||||
END hmm_report_render_pkg;
|
|
||||||
/
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
DBMS_CLOUD_AI_AGENT.DROP_TOOL('HMM_CARRIER_REPORT_RENDERER', force => TRUE);
|
|
||||||
DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
|
|
||||||
tool_name => 'HMM_CARRIER_REPORT_RENDERER',
|
|
||||||
attributes => q'~{
|
|
||||||
"instruction": "Render the supplied carrier-performance payload with the approved HMM HTML template. Do not query data and do not alter the supplied values.",
|
|
||||||
"function": "HMM_REPORT_RENDER_PKG.RENDER_CARRIER_REPORT",
|
|
||||||
"tool_inputs": [{"name":"P_REPORT_JSON","description":"Normalized carrier performance report JSON."}]
|
|
||||||
}~',
|
|
||||||
status => 'ENABLED',
|
|
||||||
description => 'Renders approved HMM carrier-performance HTML from already-authorized query results.'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT tool_name, status
|
|
||||||
FROM user_ai_agent_tools
|
|
||||||
WHERE tool_name = 'HMM_CARRIER_REPORT_RENDERER';
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- 84_hmm_carrier_team_vpd.sql
|
|
||||||
--
|
|
||||||
-- Row security for HMM_CARRIER_ASSIGNMENTS_V:
|
|
||||||
-- HMM_HR_VIEWER -> current employee only
|
|
||||||
-- HMM_HR_MANAGER -> current employee and direct reports
|
|
||||||
-- HMM_HR_ADMIN -> all assignments
|
|
||||||
--
|
|
||||||
-- Run as ADMIN after 72_hmm_leave_team_vpd.sql and
|
|
||||||
-- 81_hmm_carrier_access_groups.sql. Plain bearer tokens are
|
|
||||||
-- never stored by this script.
|
|
||||||
-- ============================================================
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
SET ECHO OFF
|
|
||||||
SET FEEDBACK ON
|
|
||||||
SET DEFINE OFF
|
|
||||||
|
|
||||||
PROMPT === 1. Registering the protected carrier object ===
|
|
||||||
DECLARE
|
|
||||||
v_object_id NUMBER;
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
SELECT object_id
|
|
||||||
INTO v_object_id
|
|
||||||
FROM hmm_access_objects
|
|
||||||
WHERE owner = 'ADMIN'
|
|
||||||
AND object_name = 'HMM_CARRIER_ASSIGNMENTS_V';
|
|
||||||
|
|
||||||
UPDATE hmm_access_objects
|
|
||||||
SET enabled_yn = 'Y',
|
|
||||||
description = 'Employee-to-carrier assignments protected by employee hierarchy',
|
|
||||||
ords_path = 'cb-ords/cb-object-query/admin/hmm_carrier_assignments_v'
|
|
||||||
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',
|
|
||||||
'HMM_CARRIER_ASSIGNMENTS_V',
|
|
||||||
'cb-ords/cb-object-query/admin/hmm_carrier_assignments_v',
|
|
||||||
'Y',
|
|
||||||
'Employee-to-carrier assignments protected by employee hierarchy'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
|
|
||||||
FOR c IN (
|
|
||||||
SELECT column_name
|
|
||||||
FROM user_tab_columns
|
|
||||||
WHERE table_name = 'HMM_CARRIER_ASSIGNMENTS_V'
|
|
||||||
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;
|
|
||||||
/
|
|
||||||
|
|
||||||
PROMPT === 2. Registering role rules ===
|
|
||||||
DECLARE
|
|
||||||
PROCEDURE ensure_permission(
|
|
||||||
p_role_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 = 'HMM_CARRIER_ASSIGNMENTS_V'
|
|
||||||
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, 'HMM_CARRIER_ASSIGNMENTS_V', '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', 'SELF');
|
|
||||||
ensure_permission('HMM_HR_MANAGER', 'MANAGED_TEAM');
|
|
||||||
ensure_permission('HMM_HR_ADMIN', 'ALL');
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PROMPT === 3. Creating the carrier VPD predicate ===
|
|
||||||
CREATE OR REPLACE FUNCTION hmm_carrier_vpd_filter(
|
|
||||||
p_schema IN VARCHAR2,
|
|
||||||
p_object IN VARCHAR2
|
|
||||||
) RETURN VARCHAR2
|
|
||||||
AUTHID DEFINER
|
|
||||||
AS
|
|
||||||
v_employee_id NUMBER;
|
|
||||||
v_all_rule NUMBER := 0;
|
|
||||||
v_team_rule NUMBER := 0;
|
|
||||||
v_self_rule NUMBER := 0;
|
|
||||||
BEGIN
|
|
||||||
IF UPPER(TRIM(p_schema)) != 'ADMIN'
|
|
||||||
OR UPPER(TRIM(p_object)) != 'HMM_CARRIER_ASSIGNMENTS_V' THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
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 THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT NVL(MAX(CASE WHEN rule.rule_type = 'ALL' THEN 1 ELSE 0 END), 0),
|
|
||||||
NVL(MAX(CASE WHEN rule.rule_type = 'MANAGED_TEAM' THEN 1 ELSE 0 END), 0),
|
|
||||||
NVL(MAX(CASE WHEN rule.rule_type = 'SELF' THEN 1 ELSE 0 END), 0)
|
|
||||||
INTO v_all_rule, v_team_rule, v_self_rule
|
|
||||||
FROM (
|
|
||||||
SELECT employee_role.role_id
|
|
||||||
FROM hmm_employee_access_roles employee_role
|
|
||||||
WHERE employee_role.employee_id = v_employee_id
|
|
||||||
UNION
|
|
||||||
SELECT group_role.role_id
|
|
||||||
FROM hmm_access_group_members group_member
|
|
||||||
JOIN hmm_access_groups access_group
|
|
||||||
ON access_group.group_id = group_member.group_id
|
|
||||||
AND access_group.active_yn = 'Y'
|
|
||||||
JOIN hmm_group_access_roles group_role
|
|
||||||
ON group_role.group_id = group_member.group_id
|
|
||||||
WHERE group_member.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 permission
|
|
||||||
ON permission.role_id = role.role_id
|
|
||||||
AND permission.target_name = 'HMM_CARRIER_ASSIGNMENTS_V'
|
|
||||||
AND permission.action_name = 'SELECT'
|
|
||||||
AND permission.permission_effect = 'ALLOW'
|
|
||||||
JOIN hmm_access_permission_rules rule
|
|
||||||
ON rule.perm_id = permission.perm_id
|
|
||||||
AND UPPER(TRIM(rule.rule_column)) = 'EMPLOYEE_ID';
|
|
||||||
|
|
||||||
IF v_all_rule = 1 THEN
|
|
||||||
RETURN '1 = 1';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_team_rule = 1 THEN
|
|
||||||
RETURN 'EMPLOYEE_ID 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''))'
|
|
||||||
|| ')';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_self_rule = 1 THEN
|
|
||||||
RETURN 'EMPLOYEE_ID = TO_NUMBER('
|
|
||||||
|| 'SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN '1 = 0';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SHOW ERRORS FUNCTION hmm_carrier_vpd_filter
|
|
||||||
|
|
||||||
PROMPT === 4. Attaching the VPD policy ===
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
DBMS_RLS.DROP_POLICY(
|
|
||||||
object_schema => 'ADMIN',
|
|
||||||
object_name => 'HMM_CARRIER_ASSIGNMENTS_V',
|
|
||||||
policy_name => 'HMM_CARRIER_SCOPE_POLICY'
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE != -28102 THEN
|
|
||||||
RAISE;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
|
|
||||||
DBMS_RLS.ADD_POLICY(
|
|
||||||
object_schema => 'ADMIN',
|
|
||||||
object_name => 'HMM_CARRIER_ASSIGNMENTS_V',
|
|
||||||
policy_name => 'HMM_CARRIER_SCOPE_POLICY',
|
|
||||||
function_schema => 'ADMIN',
|
|
||||||
policy_function => 'HMM_CARRIER_VPD_FILTER',
|
|
||||||
statement_types => 'SELECT',
|
|
||||||
update_check => FALSE,
|
|
||||||
enable => TRUE,
|
|
||||||
policy_type => DBMS_RLS.CONTEXT_SENSITIVE
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
PROMPT === 5. Granting the non-exempt runtime read boundary ===
|
|
||||||
GRANT SELECT ON hmm_carrier_assignments_v TO cb_ords;
|
|
||||||
GRANT SELECT ON hmm_rds_carriers_v TO cb_ords;
|
|
||||||
GRANT SELECT ON hmm_rds_carrier_perf_v TO cb_ords;
|
|
||||||
GRANT SELECT ON hmm_rds_carrier_latest_v TO cb_ords;
|
|
||||||
|
|
||||||
CREATE OR REPLACE SYNONYM cb_ords.hmm_carrier_assignments_v
|
|
||||||
FOR admin.hmm_carrier_assignments_v;
|
|
||||||
CREATE OR REPLACE SYNONYM cb_ords.hmm_rds_carriers_v
|
|
||||||
FOR admin.hmm_rds_carriers_v;
|
|
||||||
CREATE OR REPLACE SYNONYM cb_ords.hmm_rds_carrier_perf_v
|
|
||||||
FOR admin.hmm_rds_carrier_perf_v;
|
|
||||||
CREATE OR REPLACE SYNONYM cb_ords.hmm_rds_carrier_latest_v
|
|
||||||
FOR admin.hmm_rds_carrier_latest_v;
|
|
||||||
|
|
||||||
PROMPT === 6. Recording policy notes ===
|
|
||||||
MERGE INTO hmm_access_vpd_filter_notes dst
|
|
||||||
USING (
|
|
||||||
SELECT 'ADMIN' function_owner,
|
|
||||||
'HMM_CARRIER_VPD_FILTER' function_name,
|
|
||||||
'Filters carrier assignments by the authenticated employee role and employee hierarchy.' 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,
|
|
||||||
'HMM_CARRIER_ASSIGNMENTS_V' object_name,
|
|
||||||
'HMM_CARRIER_SCOPE_POLICY' policy_name,
|
|
||||||
'Restricts carrier assignments to self, direct reports, or explicit HR administrator access.' description
|
|
||||||
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 === 7. Verification inventory ===
|
|
||||||
SELECT object_name, policy_name, function, sel, enable, policy_type
|
|
||||||
FROM all_policies
|
|
||||||
WHERE object_owner = 'ADMIN'
|
|
||||||
AND object_name = 'HMM_CARRIER_ASSIGNMENTS_V'
|
|
||||||
AND policy_name = 'HMM_CARRIER_SCOPE_POLICY';
|
|
||||||
|
|
||||||
SELECT role.role_name,
|
|
||||||
permission.target_name,
|
|
||||||
rule.rule_column,
|
|
||||||
rule.rule_type
|
|
||||||
FROM hmm_access_permissions permission
|
|
||||||
JOIN hmm_access_roles role ON role.role_id = permission.role_id
|
|
||||||
JOIN hmm_access_permission_rules rule ON rule.perm_id = permission.perm_id
|
|
||||||
WHERE permission.target_name = 'HMM_CARRIER_ASSIGNMENTS_V'
|
|
||||||
ORDER BY role.role_name;
|
|
||||||
|
|
||||||
PROMPT === HMM carrier team VPD ready ===
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
-- Redmine #749
|
|
||||||
-- Create an isolated HMM HR Select AI profile with annotations disabled.
|
|
||||||
-- The source profile is never modified.
|
|
||||||
|
|
||||||
SET SERVEROUTPUT ON SIZE UNLIMITED
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
c_source_profile CONSTANT VARCHAR2(128) := 'HMM_HR_DATA_GPT54_PROFILE';
|
|
||||||
c_target_profile CONSTANT VARCHAR2(128) := 'HMM_HR_DATA_GPT54_NOANN_PROFILE';
|
|
||||||
|
|
||||||
l_attributes CLOB;
|
|
||||||
l_object_list CLOB;
|
|
||||||
l_provider VARCHAR2(4000);
|
|
||||||
l_credential_name VARCHAR2(4000);
|
|
||||||
l_model VARCHAR2(4000);
|
|
||||||
l_region VARCHAR2(4000);
|
|
||||||
l_compartment_id VARCHAR2(4000);
|
|
||||||
l_max_tokens NUMBER;
|
|
||||||
l_temperature NUMBER;
|
|
||||||
l_profile_count PLS_INTEGER;
|
|
||||||
l_difference_count PLS_INTEGER;
|
|
||||||
l_annotations VARCHAR2(30);
|
|
||||||
|
|
||||||
FUNCTION attribute_clob(p_name IN VARCHAR2) RETURN CLOB IS
|
|
||||||
l_value CLOB;
|
|
||||||
BEGIN
|
|
||||||
SELECT attribute_value
|
|
||||||
INTO l_value
|
|
||||||
FROM user_cloud_ai_profile_attributes
|
|
||||||
WHERE profile_name = c_source_profile
|
|
||||||
AND attribute_name = p_name;
|
|
||||||
RETURN l_value;
|
|
||||||
END attribute_clob;
|
|
||||||
|
|
||||||
FUNCTION attribute_text(p_name IN VARCHAR2) RETURN VARCHAR2 IS
|
|
||||||
BEGIN
|
|
||||||
RETURN DBMS_LOB.SUBSTR(attribute_clob(p_name), 4000, 1);
|
|
||||||
END attribute_text;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO l_profile_count
|
|
||||||
FROM user_cloud_ai_profiles
|
|
||||||
WHERE profile_name = c_source_profile
|
|
||||||
AND status = 'ENABLED';
|
|
||||||
|
|
||||||
IF l_profile_count <> 1 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20080, 'Enabled source Select AI profile was not found.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
l_provider := attribute_text('provider');
|
|
||||||
l_credential_name := attribute_text('credential_name');
|
|
||||||
l_model := attribute_text('model');
|
|
||||||
l_region := attribute_text('region');
|
|
||||||
l_compartment_id := attribute_text('oci_compartment_id');
|
|
||||||
l_object_list := attribute_clob('object_list');
|
|
||||||
l_max_tokens := TO_NUMBER(
|
|
||||||
attribute_text('max_tokens'),
|
|
||||||
'9999999999',
|
|
||||||
'NLS_NUMERIC_CHARACTERS=''.,'''
|
|
||||||
);
|
|
||||||
l_temperature := TO_NUMBER(
|
|
||||||
attribute_text('temperature'),
|
|
||||||
'9999999990D999999999',
|
|
||||||
'NLS_NUMERIC_CHARACTERS=''.,'''
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO l_profile_count
|
|
||||||
FROM user_cloud_ai_profiles
|
|
||||||
WHERE profile_name = c_target_profile;
|
|
||||||
|
|
||||||
IF l_profile_count = 0 THEN
|
|
||||||
SELECT JSON_OBJECT(
|
|
||||||
'provider' VALUE l_provider,
|
|
||||||
'credential_name' VALUE l_credential_name,
|
|
||||||
'model' VALUE l_model,
|
|
||||||
'region' VALUE l_region,
|
|
||||||
'oci_compartment_id' VALUE l_compartment_id,
|
|
||||||
'object_list' VALUE l_object_list FORMAT JSON,
|
|
||||||
'max_tokens' VALUE l_max_tokens,
|
|
||||||
'temperature' VALUE l_temperature,
|
|
||||||
'annotations' VALUE 'false' FORMAT JSON
|
|
||||||
RETURNING CLOB
|
|
||||||
)
|
|
||||||
INTO l_attributes
|
|
||||||
FROM dual;
|
|
||||||
|
|
||||||
DBMS_CLOUD_AI.CREATE_PROFILE(
|
|
||||||
profile_name => c_target_profile,
|
|
||||||
attributes => l_attributes,
|
|
||||||
status => 'enabled',
|
|
||||||
description => 'HMM HR GPT-5.4 mini comparison profile with annotations disabled'
|
|
||||||
);
|
|
||||||
DBMS_OUTPUT.PUT_LINE('PROFILE_CREATED|' || c_target_profile);
|
|
||||||
ELSE
|
|
||||||
DBMS_OUTPUT.PUT_LINE('PROFILE_EXISTS|' || c_target_profile);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO l_difference_count
|
|
||||||
FROM (
|
|
||||||
SELECT attribute_name, attribute_value
|
|
||||||
FROM user_cloud_ai_profile_attributes
|
|
||||||
WHERE profile_name = c_source_profile
|
|
||||||
AND attribute_name <> 'annotations'
|
|
||||||
) source_attributes
|
|
||||||
FULL OUTER JOIN (
|
|
||||||
SELECT attribute_name, attribute_value
|
|
||||||
FROM user_cloud_ai_profile_attributes
|
|
||||||
WHERE profile_name = c_target_profile
|
|
||||||
AND attribute_name <> 'annotations'
|
|
||||||
) target_attributes
|
|
||||||
ON target_attributes.attribute_name = source_attributes.attribute_name
|
|
||||||
WHERE source_attributes.attribute_name IS NULL
|
|
||||||
OR target_attributes.attribute_name IS NULL
|
|
||||||
OR DBMS_LOB.COMPARE(
|
|
||||||
source_attributes.attribute_value,
|
|
||||||
target_attributes.attribute_value
|
|
||||||
) <> 0;
|
|
||||||
|
|
||||||
SELECT LOWER(TRIM(DBMS_LOB.SUBSTR(attribute_value, 30, 1)))
|
|
||||||
INTO l_annotations
|
|
||||||
FROM user_cloud_ai_profile_attributes
|
|
||||||
WHERE profile_name = c_target_profile
|
|
||||||
AND attribute_name = 'annotations';
|
|
||||||
|
|
||||||
IF l_difference_count <> 0 OR l_annotations <> 'false' THEN
|
|
||||||
RAISE_APPLICATION_ERROR(
|
|
||||||
-20081,
|
|
||||||
'Comparison profile differs from source beyond annotations=false.'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
DBMS_OUTPUT.PUT_LINE(
|
|
||||||
'PROFILE_VALIDATED|source=' || c_source_profile
|
|
||||||
|| '|target=' || c_target_profile
|
|
||||||
|| '|annotations=false|other_attribute_differences=0'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT profile_name, status, description
|
|
||||||
FROM user_cloud_ai_profiles
|
|
||||||
WHERE profile_name IN (
|
|
||||||
'HMM_HR_DATA_GPT54_PROFILE',
|
|
||||||
'HMM_HR_DATA_GPT54_NOANN_PROFILE'
|
|
||||||
)
|
|
||||||
ORDER BY profile_name;
|
|
||||||
|
|
||||||
SELECT profile_name, attribute_name, attribute_value
|
|
||||||
FROM user_cloud_ai_profile_attributes
|
|
||||||
WHERE profile_name IN (
|
|
||||||
'HMM_HR_DATA_GPT54_PROFILE',
|
|
||||||
'HMM_HR_DATA_GPT54_NOANN_PROFILE'
|
|
||||||
)
|
|
||||||
ORDER BY profile_name, attribute_name;
|
|
||||||
|
|
||||||
EXIT SUCCESS
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
-- Redmine #749
|
|
||||||
-- Compare Select AI prompt and SQL generation with annotations enabled/disabled.
|
|
||||||
-- The Korean prompt is reconstructed from UTF-8 Base64 inside Oracle.
|
|
||||||
|
|
||||||
SET SERVEROUTPUT ON SIZE UNLIMITED
|
|
||||||
SET FEEDBACK OFF
|
|
||||||
SET VERIFY OFF
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
c_annotation_profile CONSTANT VARCHAR2(128) := 'HMM_HR_DATA_GPT54_PROFILE';
|
|
||||||
c_no_annotation_profile CONSTANT VARCHAR2(128) := 'HMM_HR_DATA_GPT54_NOANN_PROFILE';
|
|
||||||
c_prompt_base64 CONSTANT VARCHAR2(4000) :=
|
|
||||||
'7J2067KIIOuLrCDtjIDsm5Drs4Qg7Zy06rCAIOyCrOyaqSDtmITtmansnYQg67O07Jes7KSY';
|
|
||||||
|
|
||||||
l_prompt VARCHAR2(4000) :=
|
|
||||||
UTL_I18N.RAW_TO_CHAR(
|
|
||||||
UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW(c_prompt_base64)),
|
|
||||||
'AL32UTF8'
|
|
||||||
);
|
|
||||||
|
|
||||||
PROCEDURE run_one(
|
|
||||||
p_run_no IN PLS_INTEGER,
|
|
||||||
p_action IN VARCHAR2,
|
|
||||||
p_profile_name IN VARCHAR2
|
|
||||||
) IS
|
|
||||||
l_started PLS_INTEGER;
|
|
||||||
l_elapsed_ms PLS_INTEGER;
|
|
||||||
l_result CLOB;
|
|
||||||
l_result_head VARCHAR2(32767);
|
|
||||||
l_result_hash VARCHAR2(128);
|
|
||||||
l_valid_sql VARCHAR2(1) := '-';
|
|
||||||
BEGIN
|
|
||||||
l_started := DBMS_UTILITY.GET_TIME;
|
|
||||||
l_result := DBMS_CLOUD_AI.GENERATE(
|
|
||||||
l_prompt,
|
|
||||||
p_profile_name,
|
|
||||||
p_action
|
|
||||||
);
|
|
||||||
l_elapsed_ms := (DBMS_UTILITY.GET_TIME - l_started) * 10;
|
|
||||||
l_result_head := DBMS_LOB.SUBSTR(l_result, 32767, 1);
|
|
||||||
SELECT RAWTOHEX(STANDARD_HASH(l_result_head, 'SHA256'))
|
|
||||||
INTO l_result_hash
|
|
||||||
FROM dual;
|
|
||||||
|
|
||||||
IF p_action = 'showsql' THEN
|
|
||||||
IF REGEXP_LIKE(LTRIM(l_result_head), '^(SELECT|WITH)[[:space:]]', 'i') THEN
|
|
||||||
l_valid_sql := 'Y';
|
|
||||||
ELSE
|
|
||||||
l_valid_sql := 'N';
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
DBMS_OUTPUT.PUT_LINE(
|
|
||||||
'BENCHMARK|run=' || p_run_no
|
|
||||||
|| '|action=' || p_action
|
|
||||||
|| '|profile=' || p_profile_name
|
|
||||||
|| '|elapsed_ms=' || l_elapsed_ms
|
|
||||||
|| '|result_chars=' || DBMS_LOB.GETLENGTH(l_result)
|
|
||||||
|| '|valid_sql=' || l_valid_sql
|
|
||||||
|| '|result_sha256=' || l_result_hash
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
l_elapsed_ms := (DBMS_UTILITY.GET_TIME - l_started) * 10;
|
|
||||||
DBMS_OUTPUT.PUT_LINE(
|
|
||||||
'BENCHMARK_ERROR|run=' || p_run_no
|
|
||||||
|| '|action=' || p_action
|
|
||||||
|| '|profile=' || p_profile_name
|
|
||||||
|| '|elapsed_ms=' || l_elapsed_ms
|
|
||||||
|| '|error=' || REPLACE(SUBSTR(SQLERRM, 1, 500), '|', '/')
|
|
||||||
);
|
|
||||||
END run_one;
|
|
||||||
|
|
||||||
PROCEDURE run_pair(p_run_no IN PLS_INTEGER, p_action IN VARCHAR2) IS
|
|
||||||
BEGIN
|
|
||||||
IF MOD(p_run_no, 2) = 1 THEN
|
|
||||||
run_one(p_run_no, p_action, c_annotation_profile);
|
|
||||||
run_one(p_run_no, p_action, c_no_annotation_profile);
|
|
||||||
ELSE
|
|
||||||
run_one(p_run_no, p_action, c_no_annotation_profile);
|
|
||||||
run_one(p_run_no, p_action, c_annotation_profile);
|
|
||||||
END IF;
|
|
||||||
END run_pair;
|
|
||||||
BEGIN
|
|
||||||
DBMS_OUTPUT.PUT_LINE(
|
|
||||||
'BENCHMARK_START|prompt_utf8_bytes=' ||
|
|
||||||
UTL_RAW.LENGTH(UTL_I18N.STRING_TO_RAW(l_prompt, 'AL32UTF8'))
|
|
||||||
);
|
|
||||||
|
|
||||||
FOR run_no IN 1 .. 3 LOOP
|
|
||||||
run_pair(run_no, 'showprompt');
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
FOR run_no IN 1 .. 3 LOOP
|
|
||||||
run_pair(run_no, 'showsql');
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
DBMS_OUTPUT.PUT_LINE('BENCHMARK_END');
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
EXIT SUCCESS
|
|
||||||
@@ -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
|
|
||||||
@@ -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 ===
|
|
||||||
@@ -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 인스턴스를 실행합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.security</groupId>
|
|
||||||
<artifactId>spring-security-oauth2-jose</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.oracle.database.jdbc</groupId>
|
<groupId>com.oracle.database.jdbc</groupId>
|
||||||
<artifactId>ojdbc11</artifactId>
|
<artifactId>ojdbc11</artifactId>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.cloudhandson.ddsbackoffice;
|
|||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpOidcProperties;
|
|
||||||
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
||||||
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
||||||
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
||||||
@@ -16,7 +15,7 @@ import org.springframework.context.annotation.ComponentScan;
|
|||||||
import org.springframework.context.annotation.FilterType;
|
import org.springframework.context.annotation.FilterType;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class, DdsMcpOidcProperties.class})
|
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
|
||||||
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||||
@ComponentScan(
|
@ComponentScan(
|
||||||
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.config;
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
|
||||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
|
|
||||||
public class DdsMcpOidcConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
JwtDecoder ddsMcpJwtDecoder(DdsMcpOidcProperties properties) {
|
|
||||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(properties.issuerUri()).build();
|
|
||||||
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> jwt.getAudience().contains(properties.audience())
|
|
||||||
? OAuth2TokenValidatorResult.success()
|
|
||||||
: OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
|
|
||||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
|
||||||
JwtValidators.createDefaultWithIssuer(properties.issuerUri()), audienceValidator));
|
|
||||||
return decoder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.config;
|
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
|
||||||
|
|
||||||
/** OIDC resource-server settings for externally authenticated MCP callers. */
|
|
||||||
@ConfigurationProperties(prefix = "dds.mcp.oidc")
|
|
||||||
public record DdsMcpOidcProperties(boolean enabled, String issuerUri, String audience) {
|
|
||||||
|
|
||||||
@ConstructorBinding
|
|
||||||
public DdsMcpOidcProperties {
|
|
||||||
issuerUri = trim(issuerUri);
|
|
||||||
audience = trim(audience);
|
|
||||||
if (enabled && (issuerUri.isBlank() || audience.isBlank())) {
|
|
||||||
throw new IllegalArgumentException("DDS MCP OIDC issuer-uri와 audience를 설정해야 합니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String trim(String value) {
|
|
||||||
return value == null ? "" : value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,13 +24,13 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return http
|
return http
|
||||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages", "/mcp/dds/messages"))
|
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages"))
|
||||||
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||||
.includeSubDomains(true)
|
.includeSubDomains(true)
|
||||||
.maxAgeInSeconds(31_536_000)))
|
.maxAgeInSeconds(31_536_000)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/css/**", "/js/**", "/health", "/login",
|
.requestMatchers("/css/**", "/js/**", "/health", "/login",
|
||||||
"/dds/mcp/sse", "/dds/mcp/messages", "/mcp/dds/sse", "/mcp/dds/messages").permitAll()
|
"/dds/mcp/sse", "/dds/mcp/messages").permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.httpBasic(basic -> {
|
.httpBasic(basic -> {
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.service;
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DataAccessResourceFailureException;
|
import org.springframework.dao.DataAccessResourceFailureException;
|
||||||
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
|
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
|
||||||
@Component
|
@Component
|
||||||
public class DdsMcpAuthorizationChangeListener implements ExternalAuthorizationSynchronizer {
|
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
|
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
|
||||||
private final DdsMcpEndUserPublisher publisher;
|
private final DdsMcpEndUserPublisher publisher;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import org.springframework.beans.factory.ObjectProvider;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Revalidates the MCP request bearer before every tool invocation. */
|
/** Revalidates the MCP request bearer before every tool invocation. */
|
||||||
@@ -21,32 +20,22 @@ public class DdsMcpBearerAuthenticator {
|
|||||||
private final BearerTokenService bearerTokenService;
|
private final BearerTokenService bearerTokenService;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final DdsMcpEndUserResolver endUserResolver;
|
private final DdsMcpEndUserResolver endUserResolver;
|
||||||
private final ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator;
|
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public DdsMcpBearerAuthenticator(
|
public DdsMcpBearerAuthenticator(
|
||||||
BearerTokenService bearerTokenService,
|
BearerTokenService bearerTokenService,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
DdsMcpEndUserResolver endUserResolver,
|
DdsMcpEndUserResolver endUserResolver,
|
||||||
ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator,
|
|
||||||
Clock clock
|
Clock clock
|
||||||
) {
|
) {
|
||||||
this.bearerTokenService = bearerTokenService;
|
this.bearerTokenService = bearerTokenService;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.endUserResolver = endUserResolver;
|
this.endUserResolver = endUserResolver;
|
||||||
this.oidcAuthenticator = oidcAuthenticator;
|
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DdsMcpAuthenticatedUser authenticate(String authorization) {
|
public DdsMcpAuthenticatedUser authenticate(String authorization) {
|
||||||
String plainToken = bearerValue(authorization);
|
String plainToken = bearerValue(authorization);
|
||||||
if (plainToken.chars().filter(character -> character == '.').count() == 2) {
|
|
||||||
DdsMcpOidcAuthenticator oidc = oidcAuthenticator.getIfAvailable();
|
|
||||||
if (oidc == null) {
|
|
||||||
throw denied();
|
|
||||||
}
|
|
||||||
return oidc.authenticate(plainToken);
|
|
||||||
}
|
|
||||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
|
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
|
||||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||||
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
|
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.service;
|
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.dao.DataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/** Maps a verified OIDC issuer and subject to one active HMM application user. */
|
|
||||||
@Service
|
|
||||||
public class DdsMcpExternalIdentityResolver {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
public DdsMcpExternalIdentityResolver(JdbcTemplate jdbcTemplate) {
|
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long resolveApplicationUserId(String issuer, String subject) {
|
|
||||||
if (!safe(issuer) || !safe(subject)) {
|
|
||||||
throw denied();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
List<Long> userIds = jdbcTemplate.query("""
|
|
||||||
SELECT b.application_user_id
|
|
||||||
FROM cb_external_identity_binding b
|
|
||||||
JOIN cb_app_user u ON u.user_id = b.application_user_id
|
|
||||||
WHERE b.issuer = ?
|
|
||||||
AND b.subject = ?
|
|
||||||
AND b.active = 'Y'
|
|
||||||
AND u.active = 'Y'
|
|
||||||
""", (row, ignored) -> row.getLong("application_user_id"), issuer, subject);
|
|
||||||
if (userIds.size() != 1) {
|
|
||||||
throw denied();
|
|
||||||
}
|
|
||||||
return userIds.getFirst();
|
|
||||||
} catch (AppException exception) {
|
|
||||||
throw exception;
|
|
||||||
} catch (DataAccessException exception) {
|
|
||||||
throw new AppException("DDS_CONTEXT_UNAVAILABLE: 외부 ID 매핑을 확인할 수 없습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean safe(String value) {
|
|
||||||
return value != null && !value.isBlank() && value.length() <= 512;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppException denied() {
|
|
||||||
return new AppException("AUTHORIZATION_DENIED: 외부 OIDC 사용자를 DDS에 매핑할 수 없습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.service;
|
|
||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtException;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/** Validates a signed OIDC JWT before looking up the DDS identity bridge. */
|
|
||||||
@Service
|
|
||||||
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
|
|
||||||
public class DdsMcpOidcAuthenticator {
|
|
||||||
|
|
||||||
private final JwtDecoder jwtDecoder;
|
|
||||||
private final DdsMcpExternalIdentityResolver externalIdentityResolver;
|
|
||||||
private final UserMapper userMapper;
|
|
||||||
private final DdsMcpEndUserResolver endUserResolver;
|
|
||||||
|
|
||||||
public DdsMcpOidcAuthenticator(
|
|
||||||
JwtDecoder jwtDecoder,
|
|
||||||
DdsMcpExternalIdentityResolver externalIdentityResolver,
|
|
||||||
UserMapper userMapper,
|
|
||||||
DdsMcpEndUserResolver endUserResolver
|
|
||||||
) {
|
|
||||||
this.jwtDecoder = jwtDecoder;
|
|
||||||
this.externalIdentityResolver = externalIdentityResolver;
|
|
||||||
this.userMapper = userMapper;
|
|
||||||
this.endUserResolver = endUserResolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DdsMcpAuthenticatedUser authenticate(String token) {
|
|
||||||
try {
|
|
||||||
Jwt jwt = jwtDecoder.decode(token);
|
|
||||||
long userId = externalIdentityResolver.resolveApplicationUserId(
|
|
||||||
jwt.getIssuer() == null ? null : jwt.getIssuer().toString(), jwt.getSubject());
|
|
||||||
AppUser user = userMapper.findById(userId);
|
|
||||||
if (user == null || !user.active()) {
|
|
||||||
throw denied();
|
|
||||||
}
|
|
||||||
return new DdsMcpAuthenticatedUser(user.userId(), user.username(), endUserResolver.resolve(user.userId()));
|
|
||||||
} catch (AppException exception) {
|
|
||||||
throw exception;
|
|
||||||
} catch (JwtException | IllegalArgumentException exception) {
|
|
||||||
throw denied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppException denied() {
|
|
||||||
return new AppException("AUTHORIZATION_DENIED: MCP OIDC 토큰을 확인할 수 없습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,8 +84,6 @@ public class DdsMcpVectorSearchService {
|
|||||||
log.info("DDS vector query completed with {} rows", rows.size());
|
log.info("DDS vector query completed with {} rows", rows.size());
|
||||||
return new DdsMcpVectorSearchResult(vector.query(), vector.embeddingMode(), rows.size(), List.copyOf(rows));
|
return new DdsMcpVectorSearchResult(vector.query(), vector.embeddingMode(), rows.size(), List.copyOf(rows));
|
||||||
} catch (java.sql.SQLException exception) {
|
} catch (java.sql.SQLException exception) {
|
||||||
log.warn("DDS vector query failed: sqlState={}, errorCode={}",
|
|
||||||
exception.getSQLState(), exception.getErrorCode());
|
|
||||||
throw new AppException("DDS_QUERY_FAILED: DDS 보호 객체를 조회할 수 없습니다.");
|
throw new AppException("DDS_QUERY_FAILED: DDS 보호 객체를 조회할 수 없습니다.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.cloudhandson.ddsbackoffice.service.DdsMcpSseService;
|
|||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -39,11 +38,8 @@ public class DdsMcpSseController {
|
|||||||
this.service = service;
|
this.service = service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = {"/dds/mcp/sse", "/mcp/dds/sse"}, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@GetMapping(path = "/dds/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public SseEmitter sse(
|
public SseEmitter sse(@RequestHeader(name = "Authorization", required = false) String authorization) throws IOException {
|
||||||
@RequestHeader(name = "Authorization", required = false) String authorization,
|
|
||||||
HttpServletRequest request
|
|
||||||
) throws IOException {
|
|
||||||
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
|
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
|
||||||
String sessionId = UUID.randomUUID().toString();
|
String sessionId = UUID.randomUUID().toString();
|
||||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||||
@@ -51,14 +47,11 @@ public class DdsMcpSseController {
|
|||||||
emitter.onCompletion(() -> sessions.remove(sessionId));
|
emitter.onCompletion(() -> sessions.remove(sessionId));
|
||||||
emitter.onTimeout(() -> sessions.remove(sessionId));
|
emitter.onTimeout(() -> sessions.remove(sessionId));
|
||||||
emitter.onError(error -> sessions.remove(sessionId));
|
emitter.onError(error -> sessions.remove(sessionId));
|
||||||
String messagesPath = request.getRequestURI().startsWith("/mcp/dds/")
|
emitter.send(SseEmitter.event().name("endpoint").data("/dds/mcp/messages?sessionId=" + sessionId));
|
||||||
? "/mcp/dds/messages"
|
|
||||||
: "/dds/mcp/messages";
|
|
||||||
emitter.send(SseEmitter.event().name("endpoint").data(messagesPath + "?sessionId=" + sessionId));
|
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(path = {"/dds/mcp/messages", "/mcp/dds/messages"}, consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(path = "/dds/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<?> message(
|
public ResponseEntity<?> message(
|
||||||
@RequestHeader(name = "Authorization", required = false) String authorization,
|
@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||||
@RequestParam(required = false) String sessionId,
|
@RequestParam(required = false) String sessionId,
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ backoffice:
|
|||||||
dds:
|
dds:
|
||||||
mcp:
|
mcp:
|
||||||
permission-sync-enabled: true
|
permission-sync-enabled: true
|
||||||
oidc:
|
|
||||||
# Disabled unless an external OIDC issuer is explicitly configured.
|
|
||||||
# The production issuer must use HTTPS; the isolated AD VM uses a temporary HTTP value only for PoC.
|
|
||||||
enabled: ${DDS_MCP_OIDC_ENABLED:false}
|
|
||||||
issuer-uri: ${DDS_MCP_OIDC_ISSUER_URI:}
|
|
||||||
audience: ${DDS_MCP_OIDC_AUDIENCE:dds-mcp}
|
|
||||||
iam:
|
iam:
|
||||||
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
|
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
|
||||||
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
|
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package com.cloudhandson.ddsbackoffice.service;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationChangeNotifier;
|
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier;
|
||||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ class DdsMcpAuthorizationChangeListenerTest {
|
|||||||
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
|
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
|
||||||
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
|
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
|
||||||
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
||||||
factory.registerSingleton("ddsSynchronizer", (ExternalAuthorizationSynchronizer) listener);
|
factory.registerSingleton("ddsSynchronizer", (DdsAuthorizationSynchronizer) listener);
|
||||||
|
|
||||||
new ExternalAuthorizationChangeNotifier(factory.getBeanProvider(ExternalAuthorizationSynchronizer.class))
|
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
|
||||||
.changed("PERMISSION_SAVED");
|
.changed("PERMISSION_SAVED");
|
||||||
|
|
||||||
verify(publisher).publish();
|
verify(publisher).publish();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[Service]
|
|
||||||
EnvironmentFile=/etc/hmm-poc4-audit.env
|
|
||||||
@@ -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
|
|
||||||
58
deploy/caddy/smilegate/Caddyfile
Normal file
58
deploy/caddy/smilegate/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +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'
|
|
||||||
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":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"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"},{"name":"search_carrier_performance","label":"HMM 선사 실적 Federation 조회","description":"팀원별 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 Select AI Federation으로 조회합니다.","argumentName":"query","argumentDescription":"선사 실적에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_FEDERATION_SEARCH","targetParameterName":"P_QUERY"},{"name":"render_hmm_carrier_report","label":"HMM 선사 실적 HTML 리포트","description":"이미 권한이 적용된 조회 결과를 HMM HTML 리포트로 표현합니다. 데이터를 조회하거나 값을 변경하지 않는 후속 처리 전용 Tool입니다.","argumentName":"reportJson","argumentDescription":"앞 단계의 구조화된 조회 결과와 리포트 메타데이터를 담은 JSON입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_REPORT_RENDERER","targetParameterName":"P_REPORT_JSON"}]'
|
|
||||||
|
|
||||||
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":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[Service]
|
|
||||||
EnvironmentFile=/etc/vpd-backoffice.env
|
|
||||||
@@ -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 만 보임
|
||||||
|
|||||||
@@ -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 설치 불필요.
|
||||||
|
|||||||
@@ -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` | 판정 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|||||||
@@ -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 정책 연결 확인:
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# 문서 구조 표준
|
|
||||||
|
|
||||||
## 목적
|
|
||||||
|
|
||||||
문서를 처음 보는 사람이 전체 구조를 빠르게 이해하고, 필요한 순간에만 적용 방법이나 장애 해결 세부사항으로 내려갈 수 있게 한다.
|
|
||||||
|
|
||||||
## 기본 문서 지도
|
|
||||||
|
|
||||||
```text
|
|
||||||
README.md 무엇을, 왜 하는가 / 현재 상태 / 큰그림
|
|
||||||
├── architecture.md 어떻게 구성되며 누가 무엇을 신뢰하는가
|
|
||||||
├── cookbook.md 어떻게 적용·검증·되돌리는가
|
|
||||||
└── troubleshooting.md 무엇이 실패했고 어떻게 진단·해결하는가
|
|
||||||
```
|
|
||||||
|
|
||||||
## README 필수 항목
|
|
||||||
|
|
||||||
- 목적·범위·제외 범위
|
|
||||||
- 현재 상태와 검증된 사실
|
|
||||||
- 구성요소 5~7개 이하의 개요도
|
|
||||||
- 핵심 결정 3~5개
|
|
||||||
- 상세 문서 링크와 독자가 어떤 경우에 읽어야 하는지
|
|
||||||
|
|
||||||
## 상세 문서 규칙
|
|
||||||
|
|
||||||
| 문서 | 포함할 내용 | 포함하지 않을 내용 |
|
|
||||||
|---|---|---|
|
|
||||||
| `architecture.md` | 신뢰 경계, 구성요소 책임, 상세 흐름, 데이터 모델, 설계 근거 | 긴 설치 명령과 오류 이력 |
|
|
||||||
| `cookbook.md` | 준비물, 단계, 입력값 형식, 성공 판정, rollback, 관련 troubleshooting 링크 | 설계 배경의 반복 |
|
|
||||||
| `troubleshooting.md` | 증상, 원인, 확인 명령, 해결, 재발 방지 | secret 값, 원인 없는 임시 우회 |
|
|
||||||
|
|
||||||
## 그림 규칙
|
|
||||||
|
|
||||||
- README 개요도는 경계와 흐름만 보여 주고 화살표는 가능한 한 10개 이하로 유지한다.
|
|
||||||
- 포트, claim, redirect URI, DB role처럼 세부값이 필요한 내용은 상세도 또는 표로 분리한다.
|
|
||||||
- 그림 아래에 “이 그림에서 기억할 점”을 적는다.
|
|
||||||
- Mermaid를 쓸 때는 노드 이름을 짧게 하고, 긴 설명은 표 또는 본문으로 옮긴다.
|
|
||||||
|
|
||||||
## 보안과 검증
|
|
||||||
|
|
||||||
- 비밀번호·secret·token·cookie는 예시에도 넣지 않는다.
|
|
||||||
- 각 cookbook 단계는 성공 판정과 다음 조치를 포함한다.
|
|
||||||
- 문서를 마칠 때는 링크 유효성, Mermaid 문법, 용어 일관성, secret 노출 여부를 점검한다.
|
|
||||||
@@ -61,11 +61,3 @@ docs/
|
|||||||
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
||||||
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||||
```
|
```
|
||||||
# 문서 안내
|
|
||||||
|
|
||||||
## HMM 운영 문서
|
|
||||||
|
|
||||||
- [HMM MCP·VPD 운영 가이드](runbooks/hmm-mcp-vpd-operations.md)
|
|
||||||
- [HMM MCP 토큰 설정](runbooks/hmm-mcp-token-configuration.md)
|
|
||||||
|
|
||||||
공통 MCP·VPD·Data Redaction·DDS 절차는 `main`의 공통 운영 문서를 기준으로 한다.
|
|
||||||
|
|||||||
@@ -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 단계로 계속 이동할지 결정이 필요하다.
|
||||||
|
|||||||
@@ -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 결과 유지 확인.
|
||||||
|
|
||||||
|
|||||||
@@ -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. 시그니처
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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 요청을 거부한다.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
## 목적
|
## 목적
|
||||||
|
|
||||||
|
|||||||
@@ -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 계정을 사용한다.
|
||||||
|
|
||||||
|
|||||||
@@ -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. 시그니처
|
||||||
|
|
||||||
|
|||||||
@@ -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. 시그니처
|
||||||
|
|
||||||
|
|||||||
@@ -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. 결정과 목적
|
||||||
|
|
||||||
|
|||||||
@@ -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`는 감사로그 탭에서 다음 두 쿼리를 사용한다.
|
||||||
|
|
||||||
| 함수 | 조회 대상 | 역할 |
|
| 함수 | 조회 대상 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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`에 기록한다.
|
|
||||||
@@ -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 원문을 출력하지 않는다.
|
||||||
|
|
||||||
|
|||||||
@@ -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 메타데이터 관리에서는 전체 컬럼을 유지한다.
|
|
||||||
|
|||||||
87
docs/design/703-smilegate-backoffice-finalization/README.md
Normal file
87
docs/design/703-smilegate-backoffice-finalization/README.md
Normal 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 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.
|
||||||
43
docs/design/704-smilegate-branch-isolation/README.md
Normal file
43
docs/design/704-smilegate-branch-isolation/README.md
Normal 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 전용 작업 경로에서만 수행한다.
|
||||||
|
- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.
|
||||||
70
docs/design/706-smilegate-qa-history/README.md
Normal file
70
docs/design/706-smilegate-qa-history/README.md
Normal 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의 두 테이블에 저장한다.
|
||||||
|
- 자유 질의에 임의의 정답을 부여하지 않는다.
|
||||||
@@ -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`를 비활성화하거나 제거한 뒤 사용자 역할·권한 메타데이터를
|
|
||||||
유지하는 방식으로 수행한다. 휴가 원장 데이터는 수정하거나 삭제하지 않는다.
|
|
||||||
48
docs/design/708-smilegate-select-ai-oci-genai/README.md
Normal file
48
docs/design/708-smilegate-select-ai-oci-genai/README.md
Normal 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`이다.
|
||||||
@@ -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`에 기록한다.
|
|
||||||
27
docs/design/710-smilegate-poc4-url-token-security/README.md
Normal file
27
docs/design/710-smilegate-poc4-url-token-security/README.md
Normal 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의 서버 검증 결과만 읽는 구조로 구현한다.
|
||||||
@@ -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`에 기록한다.
|
|
||||||
78
docs/design/722-configurable-data-catalog/README.md
Normal file
78
docs/design/722-configurable-data-catalog/README.md
Normal 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를 자동으로 생성·변경하지 않는다.
|
||||||
@@ -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까지 근거와 함께 완료되어 있다.
|
|
||||||
74
docs/design/723-sgmp-qa-vector-retrieval/README.md
Normal file
74
docs/design/723-sgmp-qa-vector-retrieval/README.md
Normal 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.
|
||||||
52
docs/design/726-showprompt-diagnostic-mcp/README.md
Normal file
52
docs/design/726-showprompt-diagnostic-mcp/README.md
Normal 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은 기존과 동일하게 거절한다.
|
||||||
65
docs/design/727-sgmp-qa-vector-mcp-tools/README.md
Normal file
65
docs/design/727-sgmp-qa-vector-mcp-tools/README.md
Normal 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 입력·응답 계약을 검증한다.
|
||||||
@@ -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 가상 원장이 검증된 뒤 별도 단계로 진행한다.
|
|
||||||
@@ -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에서 수행한다.
|
|
||||||
99
docs/design/731-sgmp-game-scope-resolver/README.md
Normal file
99
docs/design/731-sgmp-game-scope-resolver/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 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를 보강한다.
|
||||||
|
|
||||||
|
## 추출 게임명별 판정 계약
|
||||||
|
|
||||||
|
- ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를
|
||||||
|
하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다.
|
||||||
|
- 벡터 검색은 후보를 찾는 단계다. ADB OCI GenAI가 추출 명칭과 후보의 카탈로그 명칭,
|
||||||
|
별칭, `GAME_ID`, `GAME_PREFIX`를 비교해 후보 중 하나를 선택하거나 전체를 거절한다.
|
||||||
|
- 애플리케이션은 모델이 반환한 `gameKey`가 실제 후보 목록에 있을 때만
|
||||||
|
`supportedGames`에 넣는다. 후보 목록에 없는 식별자는 거절한다.
|
||||||
|
- 모델이 모든 후보를 거절하면 해당 원문 명칭을 `unmatchedGames`에 남긴다. 유사도 순위가
|
||||||
|
높다는 이유만으로 다른 게임에 대입하지 않는다.
|
||||||
|
- 게임 판정에 정규식, 부분문자열 매칭, 유사도 임계값을 사용하지 않는다.
|
||||||
|
- 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames`는 `gameKey` 기준으로 중복을
|
||||||
|
제거한다. `matchedGames`와 `unmatchedGames`에는 mention별 판정 근거를 유지한다.
|
||||||
|
- 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과
|
||||||
|
미매칭 게임 안내를 함께 수행한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다.
|
||||||
|
- resolver MCP의 결과에 구체 게임/테이블 하드코딩이 없는지 확인한다.
|
||||||
|
- STD-06에서 미지원 게임은 worker가 호출되지 않고, 지원 게임 결과에는 few-shot 예제, 생성 SQL,
|
||||||
|
실행 결과가 포함되는지 확인한다.
|
||||||
|
- STD-06 판정은 미매칭 게임과 지원 게임을 독립적으로 평가한다. 미매칭 게임을 답변에 명시하고
|
||||||
|
지원 게임의 요청 일자와 AU 집계를 반환하면 `PARTIAL` 성공을 `PASS`로 판정하며, 미매칭
|
||||||
|
게임 때문에 지원 게임 결과를 폐기하거나 전체 결과 없음으로 만들지 않는다.
|
||||||
|
- 기존 단일 게임 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에 연결하는 작업이 선행되어야 하며, 모델 추측으로 보완하지 않는다.
|
||||||
|
|
||||||
|
## 운영 배포 계약
|
||||||
|
|
||||||
|
- `GameScopeProperties` 클래스와 `application.yml`의 `backoffice.game-scope` 구역은 하나의
|
||||||
|
배포 단위다. Java 클래스만 반영하면 환경변수가 존재해도 기본값(`enabled=false`,
|
||||||
|
`viewName=""`)으로 바인딩되어 worker 호출이 차단된다.
|
||||||
|
- 운영 배포 후 `BACKOFFICE_GAME_SCOPE_ENABLED`,
|
||||||
|
`BACKOFFICE_GAME_SCOPE_VIEW`, `BACKOFFICE_GAME_SCOPE_MAX_SCOPES`가 실제 실행 JAR의
|
||||||
|
설정 메타데이터에 연결되는지 MCP `game_scope_resolve` 호출로 확인한다.
|
||||||
|
- 검증은 범위 판정 성공만으로 끝내지 않고, 지원 scope를 전달한 Few-shot NL2SQL이 읽기 전용
|
||||||
|
SQL 생성과 실행까지 완료하는지 확인한다.
|
||||||
42
docs/design/734-sgmp-annotation-api/README.md
Normal file
42
docs/design/734-sgmp-annotation-api/README.md
Normal 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 대상은 오류를 반환한다.
|
||||||
37
docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md
Normal file
37
docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md
Normal 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`
|
||||||
14
docs/design/736-smilegate-main-fewshot-route/README.md
Normal file
14
docs/design/736-smilegate-main-fewshot-route/README.md
Normal 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 오류를 표시
|
||||||
195
docs/design/739-game-target-contract/README.md
Normal file
195
docs/design/739-game-target-contract/README.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Smilegate 게임 대상 계약 통합 설계
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
현재 `game_query_plan`은 게임을 찾지 못하면 `STOP_NO_MATCH`, 여러 게임이면
|
||||||
|
`FAN_OUT`을 반환한다. Portal의 ReAct 모델이 이 지시를 종료 조건으로 해석하거나
|
||||||
|
일부 게임만 다음 도구로 전달하면서 다음 문제가 발생했다.
|
||||||
|
|
||||||
|
- 게임명이 없는 전체 매출 질의가 SQL 생성 전에 중단된다.
|
||||||
|
- 여러 게임 중 일부만 매칭되면 매칭된 게임의 조회도 생략될 수 있다.
|
||||||
|
- 계획 결과를 전달해도 NL2SQL worker가 질문 전체를 다시 단일 게임 벡터검색하여
|
||||||
|
범위를 축소할 수 있다.
|
||||||
|
- 게임별 사용자 마스터 물리 객체를 모델이 추측해야 한다.
|
||||||
|
|
||||||
|
## 2. 목표
|
||||||
|
|
||||||
|
질문의 게임 대상을 `NONE`, `SINGLE`, `MULTI`, `ALL` 네 가지로 통일하고,
|
||||||
|
모든 유형에서 원 질문과 전체 계획 결과를 `smilegate_fewshot_nl2sql`에 한 번
|
||||||
|
전달한다. 계획 단계는 조회 중단이나 SQL 조립을 결정하지 않는다.
|
||||||
|
|
||||||
|
## 3. 계약
|
||||||
|
|
||||||
|
`game_query_plan`은 다음 필드를 반환한다.
|
||||||
|
|
||||||
|
| 필드 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `targetType` | `NONE`, `SINGLE`, `MULTI`, `ALL` |
|
||||||
|
| `status` | `NO_TARGET`, `SUPPORTED`, `PARTIAL`, `UNMATCHED` |
|
||||||
|
| `targets` | 언급 순서를 보존한 게임 대상 배열 |
|
||||||
|
| `nextAction` | 항상 `CALL_FEWSHOT` |
|
||||||
|
| `executionMode` | `UNSCOPED`, `SINGLE`, `COMBINED`, `ALL` |
|
||||||
|
|
||||||
|
각 `targets` 항목은 다음 값을 가진다.
|
||||||
|
|
||||||
|
| 필드 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `mention` | 질문에서 추출한 게임명. `NONE`은 `null` |
|
||||||
|
| `status` | `NONE`, `MATCHED`, `UNMATCHED`, `CATALOG` |
|
||||||
|
| `gameKey`, `gameId` | DB 카탈로그가 반환한 게임 식별자 |
|
||||||
|
| `gamePrefix` | DB 카탈로그가 반환한 prefix |
|
||||||
|
| `gameName`, `aliases` | 표시명과 별칭 |
|
||||||
|
| `userMasterObjectName` | 현재 Select AI 승인 객체 중 해당 게임의 사용자 마스터 물리 객체. 없으면 `null` |
|
||||||
|
| `cosineDistance` | 후보 검색 거리. 적용되지 않으면 `null` |
|
||||||
|
| `reasonCode`, `reason` | 매칭 또는 미매칭 근거 |
|
||||||
|
|
||||||
|
유형별 처리 방식은 다음과 같다.
|
||||||
|
|
||||||
|
- `NONE`: 게임 대상 항목 하나를 모든 게임 필드가 `null`인 상태로 전달한다.
|
||||||
|
공통 거래·기준 객체 질의는 계속하되 prefix 전용 객체를 임의 선택하지 않는다.
|
||||||
|
- `SINGLE`: 질문에서 언급된 대상 한 건을 전달한다. 미매칭이면 게임 필드가
|
||||||
|
`null`인 대상도 그대로 다음 도구에 전달한다.
|
||||||
|
- `MULTI`: 언급된 대상 모두를 전달한다. 매칭·미매칭을 함께 보존한다.
|
||||||
|
- `ALL`: 게임 카탈로그의 활성 게임을 모두 반환한다. 사용자 마스터 물리 객체가
|
||||||
|
없는 게임도 `null`로 보존한다.
|
||||||
|
|
||||||
|
## 4. DB 설계
|
||||||
|
|
||||||
|
`SG_GAME_CATALOG`에 `USER_MASTER_OBJECT_NAME`을 추가한다.
|
||||||
|
|
||||||
|
- 값은 `COMN_GAME_ALIAS_BAS`/운영 게임 registry의 `GAME_PREFIX`와 현재
|
||||||
|
`USER_CLOUD_AI_PROFILE_ATTRIBUTES.object_list`, `USER_OBJECTS`를 조합해 계산한다.
|
||||||
|
- 공통 정책이나 애플리케이션 코드에는 특정 게임명, prefix, 테이블명을 넣지 않는다.
|
||||||
|
- 운영 registry에만 존재하는 게임도 카탈로그에 동기화하고 임베딩을 생성한다.
|
||||||
|
- 게임별 물리 객체가 없으면 컬럼은 `null`이다.
|
||||||
|
|
||||||
|
`SG_GAME_CATALOG_SEARCH`는 물리 객체명을 후보 결과에 포함한다.
|
||||||
|
`SG_GAME_QUERY_PLAN`은 추출된 mention마다 후보 선택 결과를 `targets`로 만들며,
|
||||||
|
`ALL`은 벡터 선택 없이 전체 카탈로그를 사용한다.
|
||||||
|
|
||||||
|
## 5. MCP와 NL2SQL 설계
|
||||||
|
|
||||||
|
`game_query_plan` 도구 설명은 “항상 먼저 호출하고 결과를 원 질문과 함께 다음
|
||||||
|
도구에 전달”하도록 바꾼다. `smilegate_fewshot_nl2sql`의 입력은 다음과 같다.
|
||||||
|
|
||||||
|
- `prompt`: 사용자가 입력한 원 질문
|
||||||
|
- `queryPlan`: 바로 앞 `game_query_plan`의 전체 JSON
|
||||||
|
- `scopeGameKey`: 하위 호환용 선택 값. 새 흐름에서는 사용하지 않는다.
|
||||||
|
|
||||||
|
NL2SQL worker는 전달된 `queryPlan`을 권위 있는 게임 범위로 사용한다.
|
||||||
|
계획이 있으면 질문 전체를 다시 게임 카탈로그 벡터검색하지 않는다.
|
||||||
|
|
||||||
|
- 공통 객체에 게임 식별 컬럼이 있으면 질문 의미에 따라 `IN`과 `GROUP BY`를
|
||||||
|
사용할 수 있다.
|
||||||
|
- 대상별 물리 객체가 다르면 제공된 객체만 사용해 `UNION ALL`할 수 있다.
|
||||||
|
- 물리 객체가 `null`인 대상을 임의 객체로 대체하지 않는다.
|
||||||
|
- `NONE`에서 생성 SQL이 prefix 전용 객체를 참조하면 실행 전에 차단한다.
|
||||||
|
- 최종 SQL은 기존과 같이 단일 `SELECT`/`WITH`, read-only transaction,
|
||||||
|
최대 행수와 timeout 제한을 적용한다.
|
||||||
|
|
||||||
|
## 6. Portal ReAct 설계
|
||||||
|
|
||||||
|
Portal은 도구 호출 순서를 Java/Python 조건문으로 강제하지 않는다. 외부 환경변수의
|
||||||
|
system prompt와 MCP 도구 설명으로 다음 절차를 안내한다.
|
||||||
|
|
||||||
|
1. `game_query_plan`을 호출한다.
|
||||||
|
2. `targetType`과 상관없이 원 질문, 전체 `queryPlan`으로
|
||||||
|
`smilegate_fewshot_nl2sql`을 한 번 호출한다.
|
||||||
|
3. SQL, 실행 결과, 매칭·미매칭 대상을 함께 설명한다.
|
||||||
|
|
||||||
|
## 7. 검증
|
||||||
|
|
||||||
|
운영 MCP에서 다음 네 질문군을 실제 호출한다.
|
||||||
|
|
||||||
|
| 유형 | 검증 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `NONE` | 게임명이 없는 전체 매출 질의가 공통 테이블로 실행되고 게임 필터가 임의 추가되지 않는다. |
|
||||||
|
| `SINGLE` | 한 게임의 AU 질의가 해당 게임 식별자/물리 객체로 실행된다. |
|
||||||
|
| `MULTI` | 지원·미지원 게임을 함께 질문해 지원 결과와 미지원 상태가 한 답변에 보존된다. |
|
||||||
|
| `ALL` | 전체 게임 질의가 전체 카탈로그 대상을 받아 공통 객체 그룹 또는 게임별 객체 결합 SQL로 실행된다. |
|
||||||
|
|
||||||
|
추가 검증:
|
||||||
|
|
||||||
|
- Maven 테스트
|
||||||
|
- Portal Python 테스트
|
||||||
|
- MCP `tools/list` schema/description 확인
|
||||||
|
- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인
|
||||||
|
- 운영 서비스 health와 Portal ReAct 실제 응답 확인
|
||||||
|
|
||||||
|
## 8. Few-shot 참조 거버넌스
|
||||||
|
|
||||||
|
Few-shot 원문 SQL은 실행 이력 그대로 검색하지 않는다. 특히 특정 게임의 물리 객체명이
|
||||||
|
포함된 예제는 다른 게임 또는 게임 미지정 질의의 벡터 후보가 될 수 있으므로, 예제는
|
||||||
|
검증·정규화·승인 단계를 거쳐야 한다.
|
||||||
|
|
||||||
|
`SG_QA_VECTOR_EXAMPLE`에는 다음 참조 메타데이터를 둔다.
|
||||||
|
|
||||||
|
| 필드 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `REFERENCE_STATUS` | `DRAFT`, `APPROVED`, `RETIRED`. 검색은 `APPROVED`만 사용한다. |
|
||||||
|
| `REFERENCE_KIND` | `SQL_TEMPLATE`, `NO_TARGET`, `OBJECT_UNAVAILABLE`, `METADATA_POLICY` |
|
||||||
|
| `TARGET_TYPE` | `NONE`, `SINGLE`, `MULTI`, `ALL`, `ANY`. 현재 game query plan과 일치하는 예제만 벡터 순위에 포함한다. |
|
||||||
|
| `OBJECT_ROLE` | 물리 테이블명이 아닌 `GAME_USER_MASTER` 등 논리 객체 역할 |
|
||||||
|
| `INSPECTION_STATUS`, `INSPECTION_NOTE` | 전수 실행·정책 검토 결과와 사유 |
|
||||||
|
| `VERIFIED_AT`, `VERIFIED_BY` | 승인 감사 이력 |
|
||||||
|
|
||||||
|
물리 테이블명은 Few-shot의 의미 규칙으로 저장하지 않는다. `SQL_TEMPLATE`은
|
||||||
|
`<RESOLVED_GAME_USER_MASTER>` 같은 논리 placeholder를 사용하며, 실제 객체는 오직
|
||||||
|
현재 `queryPlan.targets[*].userMasterObjectName`에서만 해석한다. `NO_TARGET`과
|
||||||
|
`OBJECT_UNAVAILABLE` 예제는 범위 경계 안내와 검증된 빈 결과 SQL 템플릿을 함께
|
||||||
|
프롬프트에 포함한다. 이 템플릿은 애플리케이션에서 직접 실행하거나 답변으로
|
||||||
|
반환하지 않으며, Select AI가 현재 질문의 범위를 해석해 SQL을 생성할 때만 Few-shot
|
||||||
|
근거로 사용한다.
|
||||||
|
|
||||||
|
`NONE`은 게임이 선택되지 않았다는 범위 정보이며 질의 불가 상태가 아니다. 각 경계
|
||||||
|
예제는 `OBJECT_ROLE`로 적용 대상을 밝힌다. 예를 들어 게임별 사용자 마스터 경계는
|
||||||
|
공통 판매·환불 같은 공통 객체 질의에 적용하지 않는다. 공통 객체로 답할 수 있는
|
||||||
|
질문은 게임 중립 SQL로 계속 실행하고, 게임별 객체가 본질적으로 필요한 질문만 빈
|
||||||
|
결과 또는 게임 식별 요청으로 처리한다.
|
||||||
|
|
||||||
|
`UNMATCHED`와 `OBJECT_STATUS=UNAVAILABLE`는 `NONE`과 별도 계약이다. 요청한
|
||||||
|
게임 전용 논리 객체를 현재 카탈로그에서 확인할 수 없다는 뜻이므로 다른 게임의
|
||||||
|
객체로 대체하지 않는다. 해당 객체가 반드시 필요한 연산이면 실행 가능한 읽기 전용
|
||||||
|
0행 결과로 종료한다. 이때 `NULL`을 담은 합성 1행은 빈 결과로 취급하지 않는다.
|
||||||
|
이 규칙은 특정 게임·prefix·물리 객체에 의존하지 않으며, Java 분기가 아니라 Select
|
||||||
|
AI 프로파일의 `additional_instructions`로 관리한다. 애플리케이션은 계획 전달과
|
||||||
|
read-only·객체 범위 검증만 담당한다.
|
||||||
|
|
||||||
|
공통 거래처럼 게임 식별 컬럼이 있는 객체는 게임 식별자를 SQL 리터럴로 직접 넣지
|
||||||
|
않는다. 활성 게임 별칭 카탈로그를 통해 허용 식별자를 구하는 서브쿼리 또는 `EXISTS`
|
||||||
|
조건을 사용하며, 카탈로그에는 있으나 해당 공통 객체의 데이터가 없는 경우는 다른
|
||||||
|
게임으로 대체하지 않고 빈 결과 또는 집계 `NULL`을 그대로 보존한다.
|
||||||
|
|
||||||
|
기존 예제는 삭제하지 않는다. 실행 가능성, 게임 범위 일치, 물리 객체 의존성을 전수
|
||||||
|
검사해 `RETIRED`로 분리하고, 검증된 정규화 예제만 `APPROVED`로 전환한다.
|
||||||
|
|
||||||
|
### 고객 질답 47건 후보화·승인 규칙
|
||||||
|
|
||||||
|
`SG_AI_QA_QUESTION`의 고객 Excel 47건은 `SOURCE_TYPE=CUSTOMER_QA_BENCHMARK`,
|
||||||
|
`SOURCE_CASE_ID`로 벡터 예제에 한 번씩 적재한다. 초기 상태는 모두 `DRAFT`이고,
|
||||||
|
질문·기대 포인트·기준 SQL을 embedding 문서로 사용한다. 이 상태의 원문은 운영 검색
|
||||||
|
결과에 절대 포함되지 않는다.
|
||||||
|
|
||||||
|
`SG_QA_VECTOR_APPROVE_VERIFIED_BENCHMARK`는 최신 Portal ReAct 실행이 다음을 모두
|
||||||
|
만족한 후보만 자동으로 `APPROVED`로 전환한다.
|
||||||
|
|
||||||
|
1. 고객 지원 범위가 `SUPPORTED`일 것
|
||||||
|
2. 최신 실행이 `COMPLETED`이며 고객 기준 판정이 `PASS`일 것
|
||||||
|
3. 실행 가능한 읽기 전용 SQL이 있을 것
|
||||||
|
|
||||||
|
승인 시 최신 실행 SQL과 응답으로 embedding을 재생성하고, 실행 이력의 query plan에서
|
||||||
|
확인된 `TARGET_TYPE`을 함께 보관한다. `WARN`·`FAIL`·미지원·SQL 미생성 후보는
|
||||||
|
`DRAFT / REVIEW`로 남긴다. 따라서 개선 전 SQL이 Few-shot 근거로 노출되지 않으며,
|
||||||
|
각 검색 결과는 `SOURCE_CASE_ID`로 고객 기준 항목까지 추적할 수 있다.
|
||||||
|
|
||||||
|
## 9. 변경 이력
|
||||||
|
|
||||||
|
- Redmine: `#739`
|
||||||
|
- Backoffice branch: `smilegate`
|
||||||
|
- Portal branch: `main`
|
||||||
|
- 2026-07-28: `STD-03`의 `NO_TARGET` 예제에 검증된 빈 결과 SQL 템플릿을 Few-shot
|
||||||
|
근거로 노출했다. 운영 Portal ReAct에서 `game_query_plan → fewshot_nl2sql` 경로로
|
||||||
|
생성·실행한 SQL은 `SELECT CAST(NULL AS NUMBER) ... FROM DUAL WHERE 1 = 0`이었고,
|
||||||
|
답변 이력 `422`는 `PASS`로 기록됐다. 직접 정답 실행 분기는 두지 않는다.
|
||||||
|
- 2026-07-28: `NONE`을 질의 차단으로 해석하지 않도록 경계 예제에 논리 객체 역할을
|
||||||
|
부여하고, 공통 객체 질의에는 경계 SQL을 일반화하지 않는 프롬프트 규칙을 추가했다.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user