Compare commits
11 Commits
hmm-backof
...
20c6a82338
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c6a82338 | ||
|
|
b3f2422b63 | ||
|
|
1538aadb14 | ||
|
|
404244534a | ||
|
|
ea4fc1de8b | ||
|
|
0eb68664f1 | ||
|
|
4eec33b2ed | ||
|
|
b4ed8649f1 | ||
|
|
f7295277f1 | ||
|
|
075eb46ef8 | ||
|
|
2b66e27bfb |
45
.env.example
45
.env.example
@@ -43,8 +43,7 @@ export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
||||
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
||||
export BACKOFFICE_REMEMBER_ME_KEY=""
|
||||
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
||||
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다.
|
||||
export BACKOFFICE_ORDS_BASE_URL=""
|
||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
||||
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다.
|
||||
@@ -52,41 +51,7 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
||||
|
||||
# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다.
|
||||
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}"
|
||||
export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}"
|
||||
export BACKOFFICE_SELECT_AI_PROFILE=""
|
||||
# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다.
|
||||
# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요.
|
||||
export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS"
|
||||
export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=""
|
||||
# Optional deployment-specific JSON contract. Keep project rules out of Java.
|
||||
export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE=""
|
||||
|
||||
# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
|
||||
# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
||||
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
|
||||
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]'
|
||||
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
|
||||
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
||||
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
||||
|
||||
# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
|
||||
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
|
||||
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
|
||||
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
||||
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
|
||||
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
|
||||
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
|
||||
export BACKOFFICE_MCP_TOOLS=''
|
||||
|
||||
# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다.
|
||||
export BACKOFFICE_MASKING_POLICIES=''
|
||||
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
|
||||
|
||||
# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||
export BACKOFFICE_AI_ENABLED="false"
|
||||
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
||||
@@ -105,7 +70,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
|
||||
export VPDTEST1_API_KEY=""
|
||||
export VPDTEST2_API_KEY=""
|
||||
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) ---
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
|
||||
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
||||
# 4명의 데모 유저:
|
||||
# vpduser_my → MySQL view 만 SELECT 가능
|
||||
@@ -118,7 +83,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26"
|
||||
export VPDUSER_NONE_PASSWORD="RowFilter#None26"
|
||||
|
||||
# --- (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 데모와 공존.
|
||||
export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
|
||||
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
|
||||
@@ -139,7 +104,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
|
||||
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
||||
|
||||
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||
# database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
||||
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
|
||||
export PG_PORT="5432"
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# 환경/비밀 — 절대 commit 금지
|
||||
.env
|
||||
.runtime/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -18,12 +17,6 @@ logs/
|
||||
# Java / Maven
|
||||
target/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
data/
|
||||
|
||||
# Locally downloaded development tools (for example SQLcl)
|
||||
.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` 로 반환.
|
||||
|
||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`).
|
||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`).
|
||||
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
||||
|
||||
### 3. 두 가지 구현 — VPD 와 DDS
|
||||
@@ -59,21 +59,19 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
||||
|
||||
## 소스 위치와 Git 저장 기준
|
||||
|
||||
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은
|
||||
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다.
|
||||
현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다.
|
||||
두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다.
|
||||
|
||||
| 구분 | 저장 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 |
|
||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||
| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
|
||||
| VPD/ASO/FGA 관리 백오피스 | `src/main/java/com/cloudhandson/vpdbackoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||
| VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||
| VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 |
|
||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
||||
| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 |
|
||||
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 |
|
||||
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 |
|
||||
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
|
||||
| Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot |
|
||||
| Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 |
|
||||
| Streamlit 외피 MCP 설정 | `poc4_active_source_20260714/config/mcp_servers.json` | `kb_mcp`, `kb_vector_mcp` endpoint와 allowlist |
|
||||
|
||||
Git 기준은 다음과 같다.
|
||||
|
||||
@@ -82,12 +80,14 @@ Git 기준은 다음과 같다.
|
||||
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
||||
| 운영 기준 remote | `gitea` |
|
||||
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
||||
| HMM 기준 branch | `hmm-backoffice` |
|
||||
| 기준 branch | `main` |
|
||||
| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` |
|
||||
|
||||
주의:
|
||||
|
||||
- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다.
|
||||
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다.
|
||||
- 현재 운영·현행화 기준은 `gitea/main`이다.
|
||||
- `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에 넣지 않는다.
|
||||
|
||||
---
|
||||
@@ -97,7 +97,6 @@ Git 기준은 다음과 같다.
|
||||
| 계층 | 객체 | 역할 |
|
||||
|---|---|---|
|
||||
| 원격 | `public.customers` (PG), `ecommerce_poc.customers` (MySQL) | 원본 데이터 |
|
||||
| 원격 HMM 데모 | `hmm_demo.carriers`, `hmm_demo.carrier_monthly_performance` (PG) | 가상 선사 기준정보와 최근 18개월 월간 KPI |
|
||||
| ADB - 연결 | `RDS_POSTGRES_LINK`, `RDS_LINK` | `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 로 만든 heterogeneous DB Link |
|
||||
| ADB - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
|
||||
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
||||
@@ -110,7 +109,7 @@ Git 기준은 다음과 같다.
|
||||
## 빠른 시작 (One-Click)
|
||||
|
||||
```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
|
||||
|
||||
# 1) 환경값 채우기
|
||||
@@ -168,7 +167,7 @@ $EDITOR .env
|
||||
|
||||
## 데모 시나리오 — 2×2 source access matrix
|
||||
|
||||
`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||
`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||
|
||||
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
||||
|---|---|---|---|---|
|
||||
@@ -186,7 +185,7 @@ $EDITOR .env
|
||||
(`1=0` predicate) — **deny by default**.
|
||||
* 누구든 원본 테이블 직접 접근 시도 (`@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 로 실패하는 것을
|
||||
보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다.
|
||||
|
||||
@@ -198,7 +197,7 @@ $EDITOR .env
|
||||
|
||||
## DDS 설정 핵심
|
||||
|
||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
|
||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
|
||||
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
||||
|
||||
### 사전 조건
|
||||
@@ -258,23 +257,10 @@ CREATE DATA GRANT admin.alice_apac_grant
|
||||
.
|
||||
├── run.sh # 원클릭 엔트리포인트
|
||||
├── .env.example
|
||||
├── vpd-backoffice/ # Spring Boot VPD 관리 백오피스
|
||||
│ ├── pom.xml
|
||||
│ └── src/
|
||||
├── ai-web-agent-console/ # Streamlit MCP 업무 에이전트
|
||||
│ ├── app.py
|
||||
│ ├── ai_web_agent_console/
|
||||
│ ├── config/
|
||||
│ └── tests/
|
||||
├── dds-backoffice/ # DDS 전용 Spring Boot 백오피스
|
||||
├── deploy/ # 애플리케이션별 배포 설정
|
||||
├── tests/ # 저장소 폴더 경계와 경로 규칙 검증
|
||||
├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼
|
||||
├── database/
|
||||
├── sql/
|
||||
│ ├── source/
|
||||
│ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows
|
||||
│ │ ├── postgres_hmm_carrier_performance.sql # 원격 PG: HMM 가상 선사 8개 + 월간 KPI 144 rows
|
||||
│ │ ├── postgres_hmm_carrier_performance_verify.sql # HMM 선사 실적 건수·패턴·제약·TLS·권한 검증
|
||||
│ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows
|
||||
│ └── adb/
|
||||
│ ├── 00_cleanup.sql # 멱등 teardown
|
||||
|
||||
@@ -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,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,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를 사용하지 않습니다.
|
||||
|
||||
```bash
|
||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/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/31_dds_standalone_demo_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
|
||||
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 자체를 볼 수 없습니다.
|
||||
@@ -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 관리 화면에서 발급한 임시 토큰을 사용합니다.
|
||||
|
||||
토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `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 인스턴스를 실행합니다.
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-oauth2-jose</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.oracle.database.jdbc</groupId>
|
||||
<artifactId>ojdbc11</artifactId>
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.cloudhandson.ddsbackoffice;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpOidcProperties;
|
||||
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
||||
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
||||
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
||||
@@ -16,7 +15,7 @@ import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.FilterType;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class, DdsMcpOidcProperties.class})
|
||||
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
|
||||
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||
@ComponentScan(
|
||||
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
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages", "/mcp/dds/messages"))
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages"))
|
||||
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||
.includeSubDomains(true)
|
||||
.maxAgeInSeconds(31_536_000)))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.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())
|
||||
.httpBasic(basic -> {
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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. */
|
||||
@Component
|
||||
public class DdsMcpAuthorizationChangeListener implements ExternalAuthorizationSynchronizer {
|
||||
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
|
||||
private final DdsMcpEndUserPublisher publisher;
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Revalidates the MCP request bearer before every tool invocation. */
|
||||
@@ -21,32 +20,22 @@ public class DdsMcpBearerAuthenticator {
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final UserMapper userMapper;
|
||||
private final DdsMcpEndUserResolver endUserResolver;
|
||||
private final ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator;
|
||||
private final Clock clock;
|
||||
|
||||
public DdsMcpBearerAuthenticator(
|
||||
BearerTokenService bearerTokenService,
|
||||
UserMapper userMapper,
|
||||
DdsMcpEndUserResolver endUserResolver,
|
||||
ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator,
|
||||
Clock clock
|
||||
) {
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.userMapper = userMapper;
|
||||
this.endUserResolver = endUserResolver;
|
||||
this.oidcAuthenticator = oidcAuthenticator;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public DdsMcpAuthenticatedUser authenticate(String 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);
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
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());
|
||||
return new DdsMcpVectorSearchResult(vector.query(), vector.embeddingMode(), rows.size(), List.copyOf(rows));
|
||||
} catch (java.sql.SQLException exception) {
|
||||
log.warn("DDS vector query failed: sqlState={}, errorCode={}",
|
||||
exception.getSQLState(), exception.getErrorCode());
|
||||
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.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -39,11 +38,8 @@ public class DdsMcpSseController {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping(path = {"/dds/mcp/sse", "/mcp/dds/sse"}, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter sse(
|
||||
@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||
HttpServletRequest request
|
||||
) throws IOException {
|
||||
@GetMapping(path = "/dds/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter sse(@RequestHeader(name = "Authorization", required = false) String authorization) throws IOException {
|
||||
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||
@@ -51,14 +47,11 @@ public class DdsMcpSseController {
|
||||
emitter.onCompletion(() -> sessions.remove(sessionId));
|
||||
emitter.onTimeout(() -> sessions.remove(sessionId));
|
||||
emitter.onError(error -> sessions.remove(sessionId));
|
||||
String messagesPath = request.getRequestURI().startsWith("/mcp/dds/")
|
||||
? "/mcp/dds/messages"
|
||||
: "/dds/mcp/messages";
|
||||
emitter.send(SseEmitter.event().name("endpoint").data(messagesPath + "?sessionId=" + sessionId));
|
||||
emitter.send(SseEmitter.event().name("endpoint").data("/dds/mcp/messages?sessionId=" + sessionId));
|
||||
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(
|
||||
@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
|
||||
@@ -58,12 +58,6 @@ backoffice:
|
||||
dds:
|
||||
mcp:
|
||||
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:
|
||||
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
|
||||
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.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationChangeNotifier;
|
||||
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier;
|
||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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));
|
||||
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
|
||||
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");
|
||||
|
||||
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
|
||||
@@ -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 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행
|
||||
> 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로
|
||||
> 사용합니다 — 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
|
||||
|
||||
# region 필터 변형을 켰을 때 — vpduser_both 에게 APAC 만 허용한 경우
|
||||
# (database/adb/03_seed.sql 하단 UPDATE 주석 해제 후)
|
||||
# (sql/adb/03_seed.sql 하단 UPDATE 주석 해제 후)
|
||||
sqlplus "vpduser_both/\"${VPDUSER_BOTH_PASSWORD}\"@$ADB_TNS"
|
||||
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
|
||||
-- 결과: APAC 만 보임
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Postgres (`database/source/postgres_setup.sql`)
|
||||
## Postgres (`sql/source/postgres_setup.sql`)
|
||||
|
||||
```
|
||||
DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc)
|
||||
@@ -22,12 +22,12 @@ seed rows : 12 (APAC 4 / EMEA 4 / AMER 4)
|
||||
```bash
|
||||
PGPASSWORD=$PG_PASSWORD psql \
|
||||
-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)
|
||||
@@ -47,12 +47,12 @@ PK 범위를 PG (1~12) 와 다르게 가져간 이유:
|
||||
|
||||
```bash
|
||||
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 연결을
|
||||
처리합니다. 별도 Database Gateway 설치 불필요.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로
|
||||
> 그대로 동작합니다. 이 문서는 동일한 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 매핑을 다룹니다.
|
||||
>
|
||||
> 본 변형은 **2026-05-26 실제 ADB (23.26.2.2.0) 에서 E2E 검증** 됨 (8장 결과).
|
||||
@@ -84,7 +84,7 @@ Oracle 이 **2026-04-09** 에 VPD/RAS 의 공식 후계자로 발표한 native a
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
`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` | 판정 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -1165,15 +1165,15 @@ ORDER BY rg.grantee, dg.object_name, dg.grant_name;
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 |
|
||||
| `database/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 |
|
||||
| `database/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 테스트 |
|
||||
| `database/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별 조회 테스트 |
|
||||
| `database/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 생성 |
|
||||
| `database/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/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 |
|
||||
| `sql/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 |
|
||||
| `sql/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 |
|
||||
| `sql/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 |
|
||||
| `sql/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 |
|
||||
| `sql/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable |
|
||||
| `sql/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 |
|
||||
| `sql/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 |
|
||||
| `sql/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 |
|
||||
|
||||
### 7.1 VPD + Redaction 실행 검증
|
||||
|
||||
@@ -1634,7 +1634,7 @@ ORA-20101: Authorization header must be Bearer <key>
|
||||
|
||||
### 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 정책 연결 확인:
|
||||
|
||||
|
||||
@@ -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`(대체 시 상단 표기, 삭제 금지).
|
||||
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||
```
|
||||
# 문서 안내
|
||||
|
||||
## 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
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-23
|
||||
> **추적성** — 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)
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
### 모듈/파일 구조
|
||||
|
||||
```text
|
||||
vpd-backoffice/pom.xml
|
||||
vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/
|
||||
pom.xml
|
||||
src/main/java/com/cloudhandson/vpdbackoffice/
|
||||
VpdBackofficeApplication.java
|
||||
config/
|
||||
DataSourceConfig.java
|
||||
@@ -88,13 +88,13 @@ vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/
|
||||
TokenController.java
|
||||
ProbeController.java
|
||||
DashboardController.java
|
||||
vpd-backoffice/src/main/resources/
|
||||
src/main/resources/
|
||||
application.yml
|
||||
mapper/*.xml
|
||||
templates/**/*.html
|
||||
static/css/app.css
|
||||
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)
|
||||
|
||||
- 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로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다.
|
||||
- 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다.
|
||||
- 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
|
||||
> **추적성** — 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)
|
||||
|
||||
@@ -78,8 +78,8 @@ I/O와 순수 로직 경계:
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
- SQLcl: `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
||||
- SQLcl: `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||
- SQLcl: `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
||||
- SQLcl: `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||
- Maven: `mvn test`
|
||||
- ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 함수 설계서: `cb_agent_doc_vpd_filter` (#456)
|
||||
|
||||
> **부모 설계서**: ./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. 시그니처
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
|
||||
> **추적성** — 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)
|
||||
|
||||
@@ -67,8 +67,8 @@ malicious cb_permission_rule rows
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
- `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
||||
- `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||
- `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
||||
- `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||
- `./run.sh backoffice-vpd-ords-test`
|
||||
- `mvn test`
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-28
|
||||
> **추적성** — 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`
|
||||
|
||||
## 1. 목적 (Why)
|
||||
@@ -49,7 +49,7 @@ Internet client
|
||||
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/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크.
|
||||
- Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **상태**: VPD·DDS 병행 검증 완료, 토큰 기반 객체별 Data Grant 적용 완료
|
||||
> **추적성**: 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` 정책을 만든다.
|
||||
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)을 순서대로 실행한다.
|
||||
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](../../../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`가 없으므로 소급해 매칭하지 않는다.
|
||||
4. 백오피스 실행 계정이 Autonomous의 `UNIFIED_AUDIT_TRAIL`을 조회할 수 있어야 한다. 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 사용한다. 일반 계정이면 감사 조회 권한을 가진 전용 observer 계정을 사용한다.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 함수 설계서: `addLocalSqlTrace` (#567)
|
||||
|
||||
> **부모 설계서**: ./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,7 +1,7 @@
|
||||
# 함수 설계서: `findVpdPredicate` (#567)
|
||||
|
||||
> **부모 설계서**: ./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. 시그니처
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
> **최종수정**: 2026-07-19
|
||||
> **추적성** — 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`
|
||||
> · 기존 PoC 게시: `database/adb/44_dds_mcp_local_end_user_setup.sql`
|
||||
> · 검증: `database/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call`
|
||||
> · 기존 PoC 게시: `sql/adb/44_dds_mcp_local_end_user_setup.sql`
|
||||
> · 검증: `sql/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call`
|
||||
|
||||
## 1. 결정과 목적
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
> **상태**: Implemented snapshot documented · follow-up contract pending
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-07-14
|
||||
> **추적성** — 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 명칭과 원격 archive 경로는 2026-07-14 당시의 이력이다. 현재 저장소 구조는 #742 설계서를 기준으로 한다.
|
||||
> · 스냅샷: `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`
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
@@ -19,17 +17,17 @@ PoC4 MCP AI Console이 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하
|
||||
|
||||
| 영역 | 현행 구현 |
|
||||
|---|---|
|
||||
| 저장소 위치 | `ai-web-agent-console/` 단일 폴더에 격리 |
|
||||
| 실행 진입점 | 직접 실행은 `streamlit run ai-web-agent-console/app.py --server.address 0.0.0.0 --server.port 8622` |
|
||||
| 저장소 위치 | `poc4_active_source_20260714/` 단일 폴더에 격리 |
|
||||
| 실행 진입점 | 직접 실행은 `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` |
|
||||
| Streamlit 화면 | `ai-web-agent-console/app.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 |
|
||||
| MCP registry | `ai-web-agent-console/config/mcp_servers.json` |
|
||||
| Streamlit 화면 | `apps/poc4/mcp_discovery_ui.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 |
|
||||
| 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 | `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`로 변경 가능 |
|
||||
| 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는 저장소에 포함하지 않음 |
|
||||
|
||||
현행 데이터 흐름은 다음과 같다.
|
||||
@@ -62,24 +60,25 @@ SQLite 대화 이력 저장
|
||||
|
||||
| 파일 | 책임 |
|
||||
|---|---|
|
||||
| `ai-web-agent-console/app.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/presentation.py` | 공통 UI theme와 화면 표현 보조 코드 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/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 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/questions.py` | 데모 질문 목록 |
|
||||
| `ai-web-agent-console/config/mcp_servers.json` | MCP 서버 registry |
|
||||
| `ai-web-agent-console/config/model_profiles.json` | OCI GenAI model profile registry |
|
||||
| `ai-web-agent-console/config/vpd_token_presets.json` | 데모 사용자 token preset 구조. 실제 토큰은 배포 환경에서 교체 |
|
||||
| `apps/poc4/mcp_discovery_ui.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 |
|
||||
| `apps/poc4/ui_theme.py` | UI theme 보조 코드 |
|
||||
| `src/mcp_tool_router.py` | 발견된 MCP tool descriptor를 기반으로 LLM router가 server/tool을 선택하고 tool arguments를 구성 |
|
||||
| `src/oci_genai_sdk.py` | OCI Generative AI 호출 경계. MCP/VPD token을 알지 않는 최소 completion client |
|
||||
| `src/poc3/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 |
|
||||
| `src/poc3/questions.py` | 데모 질문 목록 |
|
||||
| `config/mcp_servers.json` | MCP 서버 registry. 현재는 `endpoint_url`이 있으면 이를 우선 사용 |
|
||||
| `config/poc3_model_profiles.json` | `gpt55_oci`, `gpt54_mini_oci`, `grok43`, `llama4_maverick`, `llama33_70b` profile |
|
||||
| `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 현행 보안/비밀정보 경계
|
||||
|
||||
- `VpdTokenPreset.token`은 dataclass에서 `repr=False`이며, UI는 token을 정규화한 뒤 Authorization header에만 넣는다.
|
||||
- `_NoRedirectHandler`는 redirect 시 Authorization header가 다른 endpoint로 전달되는 것을 막는다.
|
||||
- `ai_web_agent_console.mcp_tool_router`의 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/mcp_tool_router.py`의 router는 질문과 tool descriptor만 받으며 bearer token 또는 provider credential을 받지 않는다.
|
||||
- `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`를 읽는다.
|
||||
- wallet 기본 fallback은 `/home/opc/wallet/kbaipoc`이다.
|
||||
- 저장소에는 실제 `.env`, wallet, SQLite 대화 DB, 실제 VPD bearer token 원문을 포함하지 않는다.
|
||||
@@ -98,7 +97,7 @@ SQLite 대화 이력 저장
|
||||
## 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 선언 정비
|
||||
- KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화
|
||||
- VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완
|
||||
@@ -301,8 +300,8 @@ Authorization: Bearer <current-user-vpd-token>
|
||||
배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다.
|
||||
|
||||
- 원격 실제 위치: `/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/` 바로 아래에 있었다.
|
||||
- 저장소 위치: `ai-web-agent-console/`
|
||||
- 사용자 제시 경로 `/home/opc/poc_4/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다.
|
||||
- 저장소 위치: `poc4_active_source_20260714/`
|
||||
- 포함 파일: Streamlit UI, MCP router, OCI GenAI client, 모델 profile, token preset sample, 기동/status script, requirements
|
||||
- 보안 확인: 실제 `.env`, 실제 VPD token 원문, 대화 SQLite DB는 포함하지 않았다. `vpd_token_presets.json`에는 placeholder만 있다.
|
||||
- DB 참조 경로: VPD 개발본 배포 서버에서는 `/home/opc/kbmcp/.env`의 접속 정보를 사용하고, wallet directory는 `/home/opc/wallet/kbaipoc`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다.
|
||||
@@ -381,7 +380,7 @@ ENABLED_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 감사 이벤트 저장 위치
|
||||
|
||||
@@ -438,7 +437,7 @@ DBA_FGA_AUDIT_TRAIL event_count_7d = 0
|
||||
|
||||
### 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` 스냅샷 경로는 #742에서 제거했다. HMM MCP를 통해 HR 데이터,
|
||||
표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다.
|
||||
`poc4_active_source_20260714`는 레거시 스냅샷 경로이며, 정식 서비스명은 HMM AI 업무 에이전트다. 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 프로토콜 처리의 단위 검증 지점이 없다.
|
||||
|
||||
@@ -20,15 +18,14 @@
|
||||
|
||||
| 모듈 | 책임 | Streamlit 의존 |
|
||||
| --- | --- | --- |
|
||||
| `ai-web-agent-console/ai_web_agent_console/profile.py` | 제품 프로필과 환경 override 로드 | 없음 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py` | MCP 도구 discovery 결과의 route와 arguments 구성 | 없음 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/auth_gateway.py` | 로그인·쿠키·세션 경계 | 없음 |
|
||||
| `ai-web-agent-console/ai_web_agent_console/audit.py` | 감사·증적 조회 경계 | 없음 |
|
||||
| `ai-web-agent-console/app.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 있음 |
|
||||
| `src/poc4/runtime_config.py` | `.env`, MCP 서버 JSON, VPD preset 로드와 검증 | 없음 |
|
||||
| `src/poc4/mcp_client.py` | endpoint 검증, JSON-RPC, 세션 fallback, tool discovery/call | 없음 |
|
||||
| `src/poc4/chat_store.py` | SQLite 대화 이력 CRUD | 없음 |
|
||||
| `apps/poc4/mcp_discovery_ui.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 있음 |
|
||||
|
||||
### 재사용 UI Shell과 제품 프로필
|
||||
|
||||
공통 화면 shell은 `ai-web-agent-console/ai_web_agent_console/`에서 제공하고, 특정 고객·PoC의 표현은
|
||||
공통 화면 shell은 `src/agent_console/`에서 제공하고, 특정 고객·PoC의 표현은
|
||||
`config/app_profile.json`에 둔다. 다른 프로젝트는 앱 코드를 복사·수정하지 않고 profile JSON을
|
||||
교체할 수 있다. 실제 배포에서는 `AGENT_CONSOLE_NAME`, `AGENT_CONSOLE_HEADER_DESCRIPTION`,
|
||||
`AGENT_CONSOLE_PRIMARY_COLOR` 등 `AGENT_CONSOLE_*` 환경변수가 JSON 기본값보다 우선한다.
|
||||
@@ -51,7 +48,7 @@
|
||||
2. 변경은 Redmine 이슈에 설계·검증 결과와 Git commit SHA를 함께 기록한다.
|
||||
3. Git commit message에는 Redmine 번호를 `refs #<번호>:` 형식으로 포함한다.
|
||||
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 원문을 출력하지 않는다.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702)
|
||||
|
||||
> 상태: Implemented
|
||||
> 추적: Redmine #702 / Git 브랜치: `hmm-backoffice`
|
||||
> 상태: Implementing
|
||||
> 추적: Redmine #702
|
||||
> 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN`
|
||||
|
||||
## 목적
|
||||
@@ -20,8 +20,6 @@
|
||||
| 역할 | `HMM_ACCESS_ROLES`, `HMM_EMPLOYEE_ACCESS_ROLES`, `HMM_GROUP_ACCESS_ROLES` | 직원 직접 역할과 그룹 상속 역할을 분리 |
|
||||
| 토큰 | `HMM_ACCESS_BEARER_TOKENS` | 원문은 발급 화면에서 한 번만 표시하고 SHA-256 해시만 저장 |
|
||||
| 감사 | `HMM_ACCESS_AUDIT` | 사용자·그룹·역할·토큰 변경 이력 저장 |
|
||||
| 지식 문서 | `HMM_KNOWLEDGE_DOCUMENTS` | 파일명·원문·BLOB·Abstract·생성일자를 저장 |
|
||||
| 지식 청크 | `HMM_KNOWLEDGE_CHUNKS`, `HMM_KNOWLEDGE_TAGS` | 문서별 청크와 정규화된 Tag·벡터를 저장 |
|
||||
|
||||
`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_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`에 매핑한다.
|
||||
- `/groups`는 `HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록
|
||||
논리 접근 그룹임을 화면에 명시한다.
|
||||
- `/roles`는 HMM 접근 역할만 관리한다.
|
||||
- `/roles`는 HMM 접근 역할만 관리한다. 기존 VPD 객체 권한 규칙은 이번 범위에서 노출하지 않는다.
|
||||
- `/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·로그에 저장하지 않는다.
|
||||
- DDL은 재실행 가능해야 하며 기존 HR 행을 수정하거나 삭제하지 않는다.
|
||||
- 배포 전 전체 자동 테스트를 통과시키고, 배포 후 사용자·접근 그룹·역할·토큰뿐 아니라 보호 객체,
|
||||
권한, 마스킹, 접근 검증, 지식자료, MCP, 운영 메뉴를 로그인 세션으로 전수 확인한다.
|
||||
- 배포 전 SQLcl로 테이블·FK·시드 역할을 확인하고, 배포 후 사용자·그룹·역할·토큰 화면을 HTTP
|
||||
수준에서 확인한다.
|
||||
|
||||
## 롤백
|
||||
|
||||
애플리케이션은 이전 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 메타데이터 관리에서는 전체 컬럼을 유지한다.
|
||||
|
||||
@@ -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`를 비활성화하거나 제거한 뒤 사용자 역할·권한 메타데이터를
|
||||
유지하는 방식으로 수행한다. 휴가 원장 데이터는 수정하거나 삭제하지 않는다.
|
||||
@@ -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`에 기록한다.
|
||||
@@ -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`에 기록한다.
|
||||
@@ -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까지 근거와 함께 완료되어 있다.
|
||||
@@ -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에서 수행한다.
|
||||
@@ -1,55 +0,0 @@
|
||||
# #740 HMM MCP VPD 실행 경계
|
||||
|
||||
## 목적
|
||||
|
||||
HMM MCP의 인증 사용자를 Oracle DB 세션 사용자 문맥으로 연결하고, HR 및 선사 배정 데이터를
|
||||
Oracle VPD로 제한한다. 팀원은 본인 행, 팀장은 본인과 직속 팀원 행을 조회한다.
|
||||
|
||||
## 범위와 결정사항
|
||||
|
||||
- 포털의 사용자 preset은 각각 다른 Bearer token 환경변수를 사용한다.
|
||||
- 백오피스는 token hash로 직원을 식별하고 실제 SELECT와 같은 `CB_ORDS` 연결에
|
||||
`SET_VPD_CONTEXT`를 호출한다.
|
||||
- `ADMIN`은 `EXEMPT ACCESS POLICY`가 있으므로 Select AI `SHOWSQL` 생성만 담당한다.
|
||||
- `HMM_CARRIER_ASSIGNMENTS_V.EMPLOYEE_ID`에 `SELF`, `MANAGED_TEAM`, `ALL` 규칙을 적용한다.
|
||||
- 기존 MCP 이름 `search_carrier_performance`, 자연어 Select AI, 선사 `CARRIER_CODE` 조인과 HTML
|
||||
renderer는 유지한다. 고정 SQL이나 대체 조회 패키지를 만들지 않는다.
|
||||
- 연속 호출에서도 renderer 입력이 달라지지 않도록 Select AI 질의 계약이 출력 컬럼의 ASCII
|
||||
uppercase underscore 별칭을 고정한다.
|
||||
|
||||
## 전체 구성
|
||||
|
||||
```text
|
||||
포털 사용자 preset
|
||||
→ 사용자별 Bearer token
|
||||
→ token hash로 EMPLOYEE_ID 확인
|
||||
→ ADMIN Select AI SHOWSQL 생성
|
||||
→ CB_ORDS 연결에서 SET_VPD_CONTEXT
|
||||
→ 생성 SELECT 실행
|
||||
→ HMM_CARRIER_ASSIGNMENTS_V VPD
|
||||
├─ E1001 팀장: 직속 팀원 배정 8건
|
||||
└─ E1002 팀원: 본인 배정 C001·C002 2건
|
||||
→ 허용된 CARRIER_CODE만 원격 KPI와 조인
|
||||
→ HTML renderer
|
||||
```
|
||||
|
||||
핵심은 token 소유자, DB context의 직원, VPD predicate가 동일한 요청 안에서 이어지는 것이다.
|
||||
`SET_VPD_CONTEXT`를 호출해도 `EXEMPT ACCESS POLICY`를 가진 연결에서 SELECT하면 정책이 우회된다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
2026-08-10 운영 적용 및 MCP 호출 검증을 완료했다.
|
||||
|
||||
- 사용자별 portal token 5개가 서로 다른 hash로 직원에게 연결됨
|
||||
- `HMM_CARRIER_SCOPE_POLICY` 활성화
|
||||
- MCP E1001: `vpdEnforced=true`, 8건
|
||||
- MCP E1002: `vpdEnforced=true`, 2건, 직원 범위 E1002만 포함
|
||||
- E1002 renderer: 입력 2건, HTML에 C001·C002 포함, E1003 미포함
|
||||
- 동일 E1002 조회와 renderer를 두 번 연속 호출해 두 호출 모두 표준 컬럼 9개와 HTML 2건을 확인
|
||||
|
||||
## 문서 지도
|
||||
|
||||
- [아키텍처와 신뢰 경계](architecture.md)
|
||||
- [적용 및 검증 절차](cookbook.md)
|
||||
- [문제 해결](troubleshooting.md)
|
||||
- [HTML 리포트 후속 처리](../hmm-html-report-mcp/README.md)
|
||||
@@ -1,56 +0,0 @@
|
||||
# HMM MCP VPD 아키텍처
|
||||
|
||||
[개요](README.md) · [적용 절차](cookbook.md) · [문제 해결](troubleshooting.md)
|
||||
|
||||
## 권한 흐름
|
||||
|
||||
```text
|
||||
포털 사용자 preset
|
||||
→ 사용자별 Bearer token
|
||||
→ token hash로 EMPLOYEE_ID 식별
|
||||
→ HMM_ACCESS_CTX.EMPLOYEE_ID 설정
|
||||
→ Select AI가 만든 SELECT를 CB_ORDS에서 실행
|
||||
→ HMM_CARRIER_ASSIGNMENTS_V VPD
|
||||
├─ VIEWER: 본인 EMPLOYEE_ID
|
||||
└─ MANAGER: 본인과 직속 팀원 EMPLOYEE_ID
|
||||
→ 허용된 CARRIER_CODE만 원격 선사 실적과 조인
|
||||
→ HTML renderer
|
||||
```
|
||||
|
||||
선사 조회의 자연어 처리와 `CARRIER_CODE` 조인은 기존 Select AI 프로필을 유지한다. 권한 조건을
|
||||
질문이나 고정 SQL에 넣지 않고 Oracle VPD가 세션 사용자 ID로 자동 적용한다.
|
||||
|
||||
## 출력 스키마 계약
|
||||
|
||||
Select AI는 같은 의미의 컬럼도 호출마다 `CARRIER_CODE`, `carrier Code`, `선사코드`처럼 다른
|
||||
alias로 생성할 수 있다. `vpd-backoffice/config/hmm_carrier_query_contract.json`은 SQL을 고정하지
|
||||
않고 다음 결과 alias만 고정한다.
|
||||
|
||||
```text
|
||||
EMPLOYEE_CODE, EMPLOYEE_NAME, MANAGER_EMPLOYEE_CODE,
|
||||
CARRIER_CODE, CARRIER_NAME, LATEST_REVENUE_USD,
|
||||
LATEST_GROSS_MARGIN_USD, LATEST_SCHEDULE_RELIABILITY_PCT,
|
||||
LATEST_RISK_LEVEL
|
||||
```
|
||||
|
||||
따라서 권한에 따라 행 수는 달라져도 renderer에 전달되는 행 구조는 매 호출 동일하다.
|
||||
|
||||
## 신뢰 경계
|
||||
|
||||
- 화면에 표시된 사용자 코드는 권한 근거가 아니다. 선택 preset의 전용 Bearer token이 근거다.
|
||||
- 토큰 원문은 서버 환경에만 저장하고 DB에는 SHA-256 hash만 저장한다.
|
||||
- `ADMIN`은 `EXEMPT ACCESS POLICY`가 있으므로 SQL 생성만 수행한다.
|
||||
- 실제 SELECT는 비면제 계정 `CB_ORDS`가 같은 요청의 VPD context를 설정한 뒤 실행한다.
|
||||
- renderer는 이미 필터링된 행만 표현하며 사용자나 권한을 다시 판단하지 않는다.
|
||||
|
||||
## 선사 VPD 규칙
|
||||
|
||||
보호 객체는 `ADMIN.HMM_CARRIER_ASSIGNMENTS_V`, 기준 컬럼은 `EMPLOYEE_ID`다.
|
||||
|
||||
| 역할 | 규칙 | 적용 범위 |
|
||||
|---|---|---|
|
||||
| `HMM_HR_VIEWER` | `SELF` | 현재 인증 사용자 |
|
||||
| `HMM_HR_MANAGER` | `MANAGED_TEAM` | 현재 사용자와 직속 팀원 |
|
||||
| `HMM_HR_ADMIN` | `ALL` | 명시적 관리자 전체 |
|
||||
|
||||
컨텍스트, 활성 역할 또는 허용 규칙이 없으면 `1=0`으로 차단한다.
|
||||
@@ -1,28 +0,0 @@
|
||||
# HMM MCP VPD 적용 절차
|
||||
|
||||
[개요](README.md) · [아키텍처](architecture.md) · [문제 해결](troubleshooting.md)
|
||||
|
||||
1. `ADMIN`이 VPD 우회 권한을 가지고 `CB_ORDS`는 가지지 않는지 확인한다.
|
||||
2. `database/adb/84_hmm_carrier_team_vpd.sql`을 ADMIN으로 실행한다.
|
||||
3. 포털 preset마다 `HMM_MCP_BEARER_TOKEN_<EMPLOYEE_CODE>` 전용 토큰을 발급한다.
|
||||
4. 선사 조회 MCP는 기존 `HMM_RDS_FEDERATION_PROFILE`로 `SHOWSQL`을 생성하고 `CB_ORDS`에서 실행한다.
|
||||
운영 도구 정의는 이름과 입력 계약을 유지하고 `executionType`만 `SELECT_AI`로 설정한다.
|
||||
`BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE`에는 배포한
|
||||
`config/hmm_carrier_query_contract.json`의 절대 경로를 설정한다.
|
||||
5. 서비스를 재시작하고 동일 질문을 E1001과 E1002로 각각 호출한다.
|
||||
|
||||
성공 기준:
|
||||
|
||||
| 사용자 | 허용된 선사 배정 | 예상 건수 |
|
||||
|---|---|---:|
|
||||
| E1001 팀장 | 직속 팀원 C001~C008 | 8 |
|
||||
| E1002 팀원 | 본인 C001, C002 | 2 |
|
||||
|
||||
첫 조회 결과, renderer 입력, HTML 표의 행 수와 직원 범위가 모두 같아야 한다. 토큰 원문과 DB
|
||||
비밀번호는 명령·로그·문서에 출력하지 않는다.
|
||||
|
||||
MCP 응답에서 `vpdEnforced=true`, `scopeEmployeeCode`가 선택 사용자와 같은지도 확인한다.
|
||||
같은 E1002 질문을 두 번 실행해 두 응답 모두 표준 alias 9개와 C001·C002 두 행을 반환하는지 확인한다.
|
||||
|
||||
롤백할 때는 VPD 정책을 삭제하지 말고 disable하여 조사 가능 상태로 보존한다. 사용자별 token
|
||||
환경변수도 공용 token으로 되돌리지 않고 사용자 선택 기능을 일시 비활성화한다.
|
||||
@@ -1,27 +0,0 @@
|
||||
# HMM MCP VPD 문제 해결
|
||||
|
||||
[개요](README.md) · [아키텍처](architecture.md) · [적용 절차](cookbook.md)
|
||||
|
||||
## 사용자를 바꿔도 같은 결과
|
||||
|
||||
- 원인: preset들이 같은 token 환경변수를 참조하거나 선사 View에 VPD가 없다.
|
||||
- 확인: token 원문을 출력하지 않고 preset 환경변수 이름과 token hash의 employee mapping을 확인한다.
|
||||
- 해결: 사용자별 token을 발급하고 `HMM_CARRIER_SCOPE_POLICY`를 활성화한다.
|
||||
|
||||
## 컨텍스트는 다른데 전체 행이 보임
|
||||
|
||||
- 원인: 최종 SELECT가 `EXEMPT ACCESS POLICY`를 가진 ADMIN에서 실행됐다.
|
||||
- 확인: 실행 계정의 `SESSION_PRIVS`와 MCP 응답의 `scopeEmployeeCode`를 확인한다.
|
||||
- 해결: ADMIN은 Select AI `SHOWSQL`만 수행하고 실제 SELECT는 `CB_ORDS`에서 실행한다.
|
||||
|
||||
## 팀원 결과가 0건
|
||||
|
||||
- 원인: token→직원, 직원→역할, 직원→선사 access group 중 하나가 누락됐다.
|
||||
- 확인: 위 순서로 원장 연결을 확인한다.
|
||||
- 해결: 누락된 원장만 보강한다. 조회 함수에 직원 코드나 결과 행을 하드코딩하지 않는다.
|
||||
|
||||
## 원격 선사 KPI가 과다 노출됨
|
||||
|
||||
- 원인: 생성 SQL이 로컬 `HMM_CARRIER_ASSIGNMENTS_V` 없이 원격 KPI View만 읽었다.
|
||||
- 해결: Select AI profile metadata에서 직원·팀 질의는 배정 View와 KPI View를 `CARRIER_CODE`로
|
||||
조인하도록 유지한다. SQL 자체는 고정하지 않는다.
|
||||
@@ -1,112 +0,0 @@
|
||||
# #742 애플리케이션 단위 저장소 구조 개편
|
||||
|
||||
## 문제
|
||||
|
||||
현재 저장소 루트의 `pom.xml`, `src/main`, `src/test`는 실제로 하나의 Spring Boot
|
||||
백오피스지만 제품 폴더 없이 저장소 전체 애플리케이션처럼 보인다. Streamlit 앱은
|
||||
`poc4_active_source_20260714`라는 날짜 스냅샷 폴더에 있고 내부에서도 `poc3`, `poc4`,
|
||||
`agent_console`, 단독 Python 모듈이 서로 참조한다.
|
||||
|
||||
이 구조에서는 다음 내용을 파일 경로만 보고 알 수 없다.
|
||||
|
||||
- 어떤 파일이 실제 배포 애플리케이션인지
|
||||
- Spring Boot와 Streamlit의 빌드·테스트 경계가 어디인지
|
||||
- `poc3`, `poc4`가 제품명인지 과거 실험 번호인지
|
||||
- DB 스크립트와 특정 애플리케이션의 관계가 무엇인지
|
||||
|
||||
## 목표 구조
|
||||
|
||||
```text
|
||||
vpd-permission-poc/
|
||||
├── vpd-backoffice/ Spring Boot VPD·권한 관리 백오피스
|
||||
│ ├── pom.xml
|
||||
│ ├── src/main/
|
||||
│ └── src/test/
|
||||
├── ai-web-agent-console/ Streamlit AI 업무 에이전트
|
||||
│ ├── app.py
|
||||
│ ├── ai_web_agent_console/ 단일 Python 패키지
|
||||
│ ├── config/
|
||||
│ ├── tests/
|
||||
│ └── requirements.txt
|
||||
├── dds-backoffice/ 별도 DDS 백오피스
|
||||
├── database/
|
||||
│ ├── adb/
|
||||
│ └── source/
|
||||
├── deploy/ 제품별 배포 설정
|
||||
├── docs/ 설계·운영·검증 문서
|
||||
├── scripts/ 저장소 공통 자동화
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 이동 매핑
|
||||
|
||||
| 기존 | 변경 | 처리 |
|
||||
|---|---|---|
|
||||
| `pom.xml` | `vpd-backoffice/pom.xml` | Maven 프로젝트 경계 명시 |
|
||||
| `src/main` | `vpd-backoffice/src/main` | Spring 애플리케이션 소스 |
|
||||
| `src/test` | `vpd-backoffice/src/test` | Spring 단위·통합 테스트 |
|
||||
| `sql/adb` | `database/adb` | Oracle/ADB 스크립트 |
|
||||
| `sql/source` | `database/source` | 외부 원천 DB 스크립트 |
|
||||
| `poc4_active_source_20260714` | `ai-web-agent-console` | 날짜·PoC 번호 제거 |
|
||||
| `apps/poc4/mcp_discovery_ui.py` | `ai-web-agent-console/app.py` | 명확한 Streamlit 진입점 |
|
||||
| `src/agent_console/*` | `ai-web-agent-console/ai_web_agent_console/*` | 단일 제품 패키지 |
|
||||
| `src/poc3/model_registry.py` | `ai-web-agent-console/ai_web_agent_console/model_registry.py` | 실험 번호 제거 |
|
||||
| `src/poc3/questions.py` | `ai-web-agent-console/ai_web_agent_console/questions.py` | 실험 번호 제거 |
|
||||
| `src/poc4/scenarios.py` | `ai-web-agent-console/ai_web_agent_console/scenarios.py` | 실험 번호 제거 |
|
||||
| `src/poc4/query_contracts.py` | `ai-web-agent-console/ai_web_agent_console/query_contracts.py` | 실험 번호 제거 |
|
||||
| `src/mcp_*.py`, `src/oci_genai_sdk.py` | `ai-web-agent-console/ai_web_agent_console/` | 앱 전용 공용 모듈 통합 |
|
||||
| `config/poc3_model_profiles.json` | `ai-web-agent-console/config/model_profiles.json` | 실험 번호 제거 |
|
||||
|
||||
## 경로 규칙
|
||||
|
||||
1. 저장소 루트에는 애플리케이션 프레임워크의 `src`나 `pom.xml`을 두지 않는다.
|
||||
2. 각 애플리케이션은 자신의 소스, 의존성 선언, 테스트 진입점을 가진다.
|
||||
3. DB 스크립트는 `database`를 Git 원본으로 사용하고 Spring JAR는 해당 경로를
|
||||
리소스로 포함한다.
|
||||
4. 배포 설정은 `deploy/<application>`에서 새 소스 경로를 참조한다.
|
||||
5. 과거 경로를 유지하는 심볼릭 링크나 복제본은 만들지 않는다. 잘못된 경로가 다시
|
||||
사용되면 테스트가 실패하도록 한다.
|
||||
6. 운영 중인 환경변수와 SQLite 테이블명은 데이터 호환을 위해 이번 변경에서 유지한다.
|
||||
폴더·패키지·실행 경로에서만 PoC 명칭을 제거한다.
|
||||
|
||||
## Maven 변경
|
||||
|
||||
`vpd-backoffice/pom.xml`의 SQL 리소스 경로는 `../database/adb`를 사용한다. 루트 자동화는
|
||||
`mvn -f vpd-backoffice/pom.xml`을 사용하며 앱 디렉토리 안에서는 기존처럼 `mvn test`가
|
||||
동작해야 한다.
|
||||
|
||||
Java 테스트에서 파일을 직접 여는 경로는 앱 디렉토리를 기준으로 유지한다. 따라서
|
||||
`vpd-backoffice`에서 실행한 테스트와 루트에서 `-f`로 실행한 테스트를 모두 확인한다.
|
||||
|
||||
## Python 변경
|
||||
|
||||
`ai-web-agent-console/app.py`는 자신의 부모 폴더를 애플리케이션 루트로 사용한다.
|
||||
모든 import는 `ai_web_agent_console.<module>` 형식으로 통일한다. 테스트도 같은 공개
|
||||
패키지 경로만 사용한다.
|
||||
|
||||
`model_profiles.json` 안의 `default_for_poc3` 속성은 `default_for_console`로 바꾸고
|
||||
로더·검증 코드도 함께 변경한다. 배포된 비밀정보 파일과 대화 DB는 Git 이동 대상이
|
||||
아니다.
|
||||
|
||||
## 배포 변경
|
||||
|
||||
- Spring 배포 빌드는 `vpd-backoffice/pom.xml`과 `vpd-backoffice/target`을 사용한다.
|
||||
- Streamlit 실행은 `streamlit run app.py`를 사용한다.
|
||||
- 인증 게이트웨이는 `python -m ai_web_agent_console.auth_gateway`를 사용한다.
|
||||
- 기존 운영 디렉토리 `/opt/hmm-poc4`는 무중단 전환을 위해 물리 경로로 유지할 수 있지만,
|
||||
서비스의 Git 소스 경로와 Python 모듈명은 새 구조를 사용한다.
|
||||
|
||||
## 검증
|
||||
|
||||
1. `git status --short`에서 기존 #741 변경이 새 경로에 남아 있는지 확인한다.
|
||||
2. `mvn -f vpd-backoffice/pom.xml test`를 실행한다.
|
||||
3. Python 전체 소스에 `compileall`을 실행한다.
|
||||
4. `unittest discover -s ai-web-agent-console/tests`를 실행한다.
|
||||
5. `rg`로 운영 코드의 `src.poc3`, `src.poc4`, `src.agent_console`,
|
||||
`poc4_active_source_20260714`, `apps/poc4` 참조가 0건인지 확인한다.
|
||||
6. 배포 스크립트의 dry-run 또는 정적 경로 검증을 수행한다.
|
||||
|
||||
## 롤백
|
||||
|
||||
모든 이동은 한 Git 커밋으로 추적한다. 문제가 생기면 해당 커밋을 revert하여 이전 경로와
|
||||
import를 함께 복구한다. DB 스키마와 운영 데이터는 이 구조 변경에서 수정하지 않는다.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Windows AD → OCI IAM → DDS 권한 매핑 (#744)
|
||||
|
||||
> **상태**: Keycloak 기반 Alice 허용/Bob 기본 거부 검증 완료 · OCI IAM AD Bridge 전환 진행 중
|
||||
> **최종수정**: 2026-08-05
|
||||
|
||||
## 한눈에 보기
|
||||
|
||||
Windows AD의 계정으로 AIPF MCP에 로그인하고, Oracle Deep Sec(DDS)이 사용자마다 다른 데이터를 보이도록 하는 PoC다. 초기에는 Keycloak이 AD 인증을 OIDC token으로 바꿨다. 현재는 Keycloak을 OCI Identity Domain의 **AD Bridge + delegated authentication**으로 대체하는 중이다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["AD 사용자<br/>Alice / Bob"] --> A["Windows AD DS<br/>계정·비밀번호 원천"]
|
||||
A <--> B["OCI AD Bridge<br/>동기화·인증 위임"]
|
||||
B <--> I["OCI Identity Domain<br/>SSO·OIDC token 발급"]
|
||||
I --> P["AIPF<br/>MCP OAuth 연결"]
|
||||
P --> M["DDS MCP<br/>JWT 검증·사용자 매핑"]
|
||||
M --> D["Oracle DDS<br/>DATA GRANT 집행"]
|
||||
```
|
||||
|
||||
이 그림에서 기억할 점: **AD는 비밀번호를 확인하고, OCI Identity Domain은 로그인 화면과 JWT를 발급하며, Oracle DDS는 최종 데이터 권한을 강제한다.** AD Bridge는 그 사이를 연결하는 Windows 서비스다.
|
||||
|
||||
## 현재 검증 결과
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Keycloak → MCP → DDS 경로 | 완료 |
|
||||
| `dds-alice`의 `휴가` 검색 | `휴가 정책 - Alice 전용 테스트` 1건 반환 |
|
||||
| `dds-bob`의 같은 검색 | DATA GRANT 없음으로 default deny (`ORA-00942` 은닉) |
|
||||
| OCI AIPF OAuth client | `AIPF_DDS_AD_TEST` 생성 및 OIDC discovery 확인 완료 |
|
||||
| OCI AD Bridge client / AD 동기화 | 설치 완료 · Alice/Bob 2명과 `DDS-DDS-Users` 1개 import 성공 |
|
||||
| OCI delegated authentication | **차단**: 이 Identity Domain에서 기능이 beta 미활성화 상태. OCI tenancy enablement 필요 |
|
||||
|
||||
## 핵심 결정
|
||||
|
||||
1. 사용자 token과 DB service token은 분리한다.
|
||||
- 사용자 token: 누가 MCP를 호출했는가
|
||||
- DB service token: MCP service가 DDS context를 열 수 있는가
|
||||
2. 사람별 권한 키는 검증된 `issuer + sub`이며, UPN/email은 표시·감사용이다.
|
||||
3. DB 권한은 MCP가 아닌 Oracle DDS DATA ROLE/DATA GRANT에서 default deny로 집행한다.
|
||||
4. OCI 전환은 기존 Keycloak을 즉시 제거하지 않고, OCI 경로의 Alice/Bob 결과가 같은지 확인한 뒤에만 완료한다.
|
||||
|
||||
## 문서 지도
|
||||
|
||||
| 필요한 경우 | 읽을 문서 |
|
||||
|---|---|
|
||||
| 전체 신뢰 경계, token 두 종류, DB/DDS 매핑 구조를 이해할 때 | [아키텍처 상세](architecture.md) |
|
||||
| OCI AD Bridge 설치, OCI OAuth client 등록, AIPF/MCP 전환을 수행할 때 | [적용 Cookbook](cookbook.md) |
|
||||
| 로그인·callback·token·LDAPS·권한 오류를 진단할 때 | [트러블슈팅](troubleshooting.md) |
|
||||
| 저장소 전체 문서 작성 구조를 확인할 때 | [문서 구조 표준](../../DOCUMENTATION-STANDARDS.md) |
|
||||
|
||||
## 전환 완료 기준
|
||||
|
||||
- OCI AD Bridge가 `dds-alice`, `dds-bob`과 필요한 AD 그룹을 동기화한다.
|
||||
- OCI tenancy에서 delegated authentication capability가 enable된 뒤, OCI Identity Domain login이 두 사용자의 **AD 비밀번호**로 delegated authentication을 통과한다.
|
||||
- AIPF가 OCI OAuth login 후 MCP에 연결된다.
|
||||
- OCI JWT의 `issuer + sub`가 각각 `DDS_U_1`, `DDS_U_2`로 매핑된다.
|
||||
- 동일한 `휴가` 검색에서 Alice는 1건을 받고 Bob은 default deny가 유지된다.
|
||||
|
||||
## 보안 원칙
|
||||
|
||||
AD 사용자 비밀번호, OAuth client secret, OCI service client secret, access/refresh token은 문서·Git·대화에 적지 않는다. 위치, rotation 절차, 안전한 입력 방법만 [Cookbook](cookbook.md)에 기록한다.
|
||||
@@ -1,722 +0,0 @@
|
||||
# 설계서: Windows AD 기반 DDS END USER 매핑 테스트 (#744)
|
||||
|
||||
> **상태**: Keycloak 기반 검증 완료 · OCI IAM AD Bridge + delegated authentication 전환 진행 중
|
||||
> **최종수정**: 2026-08-05
|
||||
> **추적성**: Redmine #744 · 선행 설계: [DDS MCP END USER Context](../617-dds-mcp-end-user-context/README.md)
|
||||
|
||||
[개요로 돌아가기](README.md) · [적용 Cookbook](cookbook.md) · [트러블슈팅](troubleshooting.md)
|
||||
|
||||
## 1. 목적과 범위
|
||||
|
||||
Microsoft Entra ID 테넌트가 아직 준비되지 않은 상황에서, OCI 격리 네트워크의 Windows Server AD DS를 이용해 업무 사용자 디렉터리와 DDS END USER 매핑을 먼저 검증한다.
|
||||
|
||||
이 환경은 **Microsoft Entra access token을 Oracle DB가 직접 검증하는 환경이 아니다.** AD DS는 사용자·그룹·UPN을 제공하고, 이후 Keycloak 또는 별도 검증 서비스가 AD LDAP을 기반으로 발급·검증한 토큰의 안정적인 subject를 MCP의 업무 사용자로 해석한다. Entra tenant와 app registration이 준비되면 OIDC issuer/audience/JWKS 검증 경로로 교체 또는 병행한다.
|
||||
|
||||
### 2026-08-05: OCI IAM AD Bridge 전환 목표
|
||||
|
||||
초기 PoC는 AD DS를 OIDC로 연결하기 위해 Keycloak을 사용했다. 이제 OCI Identity Domain의 **Microsoft Active Directory (AD) Bridge**와 **delegated authentication**을 사용해 Keycloak을 대체한다. AD DS는 계속 사용자·비밀번호의 원천이며, OCI Identity Domain이 AD Bridge를 통해 AD 비밀번호를 확인한 후 AIPF/MCP용 OIDC token을 발급한다.
|
||||
|
||||
```text
|
||||
기존: AD DS → Keycloak → Keycloak user JWT → MCP → DDS
|
||||
전환: AD DS → OCI AD Bridge + delegated authentication
|
||||
→ OCI Identity Domain user JWT → MCP → DDS
|
||||
```
|
||||
|
||||
이미 운영 중인 OCI database resource application `DDS_ORACLE_DB_TEST`와 DB service client `DDS_MCP_SERVICE_TEST`는 제거하거나 사용자 로그인 client로 재사용하지 않는다. 이들은 MCP가 Oracle DB DDS context를 여는 **machine-to-machine** 신뢰에 계속 사용한다. 사용자 로그인에는 별도의 OCI confidential OAuth application을 만든다.
|
||||
|
||||
#### 전환 원칙
|
||||
|
||||
1. Keycloak, 기존 mapping, 기존 AIPF source는 OCI 경로 검증 전까지 유지한다.
|
||||
2. OCI AD Bridge가 AD 사용자·그룹을 동기화하고 delegated authentication으로 `dds-alice`/`dds-bob`의 **AD 비밀번호**를 확인하는 것을 먼저 검증한다.
|
||||
3. AIPF 전용 OCI OAuth client를 별도로 만든다. callback URI와 refresh token 사용은 기존 AIPF 호환 설정을 유지한다.
|
||||
4. MCP는 OCI Identity Domain의 discovery/JWKS, issuer, audience로 검증 대상을 바꾼다. Keycloak token과 OCI token을 동시에 허용하지 않는다.
|
||||
5. OCI token의 검증된 `iss + sub`를 별도 binding으로 Alice=`DDS_U_1`, Bob=`DDS_U_2`에 등록한다.
|
||||
6. OCI 경로에서 Alice 1건 반환과 Bob default deny를 확인한 뒤에만 Keycloak source와 DNS/VM 정리 여부를 결정한다.
|
||||
|
||||
#### 현재 전환 인벤토리
|
||||
|
||||
| 항목 | 현재 값/상태 | 전환 시 처리 |
|
||||
|---|---|---|
|
||||
| Windows AD DS | `dds.test`, `hmm-ad-dss-test-isolated`, 실행 중 | AD 사용자 원천으로 유지 |
|
||||
| OCI Identity Domain | `identityAPAC`, 활성 | AD Bridge와 사용자 OAuth client를 이 Domain에 추가 |
|
||||
| DB resource app | `DDS_ORACLE_DB_TEST` | 유지 |
|
||||
| DB service client | `DDS_MCP_SERVICE_TEST` | 유지 |
|
||||
| 사용자 OIDC issuer | Keycloak `https://ad.cloud-handson.com/realms/dds-test` | OCI Domain issuer로 교체 |
|
||||
| AIPF OAuth client | Keycloak `aipf-dds-mcp` | OCI Domain의 새 confidential client로 교체 |
|
||||
| MCP public endpoint | `https://hmm-backoffice.cloud-handson.com/mcp/dds/sse` | 유지 |
|
||||
| DDS local users/data grants | `DDS_U_1` 허용, `DDS_U_2` grant 없음 | 유지 |
|
||||
|
||||
#### OCI AD Bridge 구축 절차
|
||||
|
||||
1. `identityAPAC`의 **Directory integrations**에서 Microsoft AD Bridge를 생성한다.
|
||||
2. OCI가 표시하는 Domain URL, Bridge client ID/secret을 기록하고 Bridge client installer를 내려받는다. 이 client secret은 AIPF OAuth client secret과 다르며, Windows Bridge client 설정에만 사용한다.
|
||||
3. AD domain에 join된 Windows VM에 Bridge client를 설치한다. 운영은 Domain Controller와 별도 member server를 권장하지만, 이 격리 PoC는 기존 AD VM에서 설치 가능 여부를 우선 검증한다.
|
||||
4. Bridge service account에는 동기화 대상 OU/그룹에 대한 read 권한과 `cn=Deleted Objects` 읽기 권한을 준다. delegated authentication도 사용할 경우 Oracle이 요구하는 password/lockout attribute 권한을 최소 범위로 부여한다.
|
||||
5. LDAPS(636)를 사용하도록 구성하고, Bridge VM에서 OCI Domain HTTPS 443 및 AD LDAP/LDAPS 연결을 확인한다.
|
||||
6. `dds-alice`, `dds-bob`이 포함된 OU와 필요한 AD 그룹만 동기화 대상으로 선택한다.
|
||||
7. OCI console의 **Delegated authentication**에서 Bridge를 시험하고 활성화한다. 성공하면 OCI 로그인 화면의 password 검증은 AD에서 수행한다.
|
||||
8. AIPF용 OCI confidential OAuth application을 생성하고, AIPF callback URI, authorization-code/refresh-token grant, `openid` scope를 설정한다.
|
||||
9. OCI discovery document의 issuer/JWKS와 token의 `aud`, `sub`를 확인한 뒤 MCP runtime과 `CB_EXTERNAL_IDENTITY_BINDING`을 교체한다.
|
||||
|
||||
#### 전체 구성도: AD 계정 로그인부터 DDS 권한 적용까지
|
||||
|
||||
아래 구성에서 AD에 새로 붙는 구성요소는 **OCI IAM AD Bridge Client**다. 이는 AD Domain Controller 내부의 AD DS를 대체하거나 AD 비밀번호를 복제하는 서버가 아니다. Domain-joined Windows에 설치되는 Windows service로서, AD에는 LDAP/LDAPS로 연결하고 OCI Identity Domain에는 HTTPS 443으로 연결한다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph USER[사용자·클라이언트]
|
||||
U["업무 사용자<br/>dds-alice / dds-bob"]
|
||||
AIPF["AIPF<br/>MCP OAuth client"]
|
||||
end
|
||||
|
||||
subgraph ADNET[OCI 격리 네트워크 · dds.test]
|
||||
AD["Windows Server 2022<br/>AD DS Domain Controller<br/>계정·비밀번호·그룹 원천"]
|
||||
BRIDGE["OCI IAM AD Bridge Client<br/>Windows service<br/>사용자/그룹 동기화 + 인증 위임"]
|
||||
AD -->|"LDAP 또는 LDAPS<br/>사용자·OU·그룹 읽기"| BRIDGE
|
||||
BRIDGE -->|"delegated authentication<br/>AD 비밀번호 확인 요청"| AD
|
||||
end
|
||||
|
||||
subgraph OCIID[OCI Identity Domain · identityAPAC]
|
||||
DOMAIN["OCI Identity Domain<br/>사용자 디렉터리·SSO·OIDC issuer"]
|
||||
OAUTH["AIPF_DDS_AD_TEST<br/>confidential OAuth client<br/>authorization_code + refresh_token"]
|
||||
DBAPP["DDS_ORACLE_DB_TEST<br/>database resource / integrated app<br/>DB_ACCESS_SCOPE"]
|
||||
SVC["DDS_MCP_SERVICE_TEST<br/>confidential service client<br/>client_credentials only"]
|
||||
DOMAIN --- OAUTH
|
||||
DOMAIN --- DBAPP
|
||||
DOMAIN --- SVC
|
||||
end
|
||||
|
||||
BRIDGE -->|"HTTPS 443<br/>AD 사용자·그룹 동기화"| DOMAIN
|
||||
U -->|"1. AIPF에서 MCP 연결"| AIPF
|
||||
AIPF -->|"2. OAuth authorization code"| OAUTH
|
||||
OAUTH -->|"3. AD 비밀번호 검증 위임"| BRIDGE
|
||||
OAUTH -->|"4. OCI user access token<br/>iss + aud + sub"| AIPF
|
||||
|
||||
subgraph MCPHOST[HMM Backoffice VM]
|
||||
MCP["DDS MCP Server<br/>hmm-backoffice.cloud-handson.com<br/>/mcp/dds/sse"]
|
||||
JWT["JWT 검증<br/>OCI discovery / JWKS<br/>issuer + audience + exp"]
|
||||
MAP["Identity binding<br/>OCI iss + sub<br/>→ CB_APP_USER → DDS_U_n"]
|
||||
DBTOKEN["DB service token 획득<br/>client credentials"]
|
||||
MCP --> JWT --> MAP
|
||||
MCP --> DBTOKEN
|
||||
end
|
||||
|
||||
AIPF -->|"5. Authorization: Bearer<br/>OCI user access token"| MCP
|
||||
MCP -->|"6. discovery/JWKS HTTPS"| DOMAIN
|
||||
DBTOKEN -->|"7. client ID + secret<br/>DB_ACCESS_SCOPE"| SVC
|
||||
SVC -->|"8. database-access token"| DBTOKEN
|
||||
|
||||
subgraph ADB[Oracle Autonomous Database · HMMAIPOC]
|
||||
APPID["DB application identity<br/>DDS_MCP_SERVICE_TEST 신뢰"]
|
||||
CTX["ORA_END_USER_CONTEXT<br/>DDS_U_1 또는 DDS_U_2 attach"]
|
||||
DDS["Oracle Deep Sec<br/>DATA ROLE / DATA GRANT<br/>default deny"]
|
||||
DATA["CB_VECTOR_SEARCH_DOCUMENTS<br/>보호 데이터"]
|
||||
APPID --> CTX --> DDS --> DATA
|
||||
end
|
||||
|
||||
DBTOKEN -->|"9. database-access token"| APPID
|
||||
MAP -->|"10. 선택된 local END USER"| CTX
|
||||
DATA -->|"11. 허용된 행만"| MCP
|
||||
MCP -->|"12. tool result"| AIPF
|
||||
```
|
||||
|
||||
| 번호 | 구간 | 전달되는 것 | 보안 의미 |
|
||||
|---:|---|---|---|
|
||||
| 1~4 | 사용자 → AIPF → OCI Domain → AD Bridge → AD | AD 사용자명·비밀번호, OAuth code, OCI user token | AD는 비밀번호 원천으로 남고 OCI Domain이 OIDC issuer 역할 수행 |
|
||||
| 5~6 | AIPF → MCP → OCI Domain | Bearer user JWT, JWKS/discovery | MCP가 서명·issuer·audience·만료를 검증해 사용자 위조 차단 |
|
||||
| 7~9 | MCP → OCI Domain → DB | service client credentials, DB 전용 access token | MCP process만 DB context를 열 수 있음; user JWT를 DB service token으로 쓰지 않음 |
|
||||
| 10~12 | MCP → DB DDS → AIPF | local DDS END USER, 필터된 query 결과 | Alice/Bob 권한은 DB DATA GRANT에서 최종 강제 |
|
||||
|
||||
#### AD Bridge가 하는 일과 하지 않는 일
|
||||
|
||||
| AD Bridge가 하는 일 | AD Bridge가 하지 않는 일 |
|
||||
|---|---|
|
||||
| AD OU의 사용자·그룹 변경을 OCI Domain으로 동기화 | AD Domain Controller 또는 LDAP 서버를 대체하지 않음 |
|
||||
| OCI Domain의 delegated authentication 요청을 AD에 전달해 AD 비밀번호를 검증 | AD 사용자 비밀번호를 MCP나 AIPF에 전달하지 않음 |
|
||||
| AD 계정 비활성화·그룹 변경을 OCI user 상태/그룹과 동기화 | AIPF OAuth client secret 또는 DB service client secret을 보관하지 않음 |
|
||||
| OCI Domain과 AD 사이의 제한된 연결을 담당 | Oracle DB의 DDS data grant를 평가하거나 우회하지 않음 |
|
||||
|
||||
Bridge 설치 위치는 운영에서는 AD Domain Controller와 분리한 domain-joined Windows member server가 원칙이다. 이 PoC는 격리된 단일 Windows VM이므로 동일 VM 설치 가능성을 확인하되, 결과 문서에는 이 제약을 남긴다. Bridge VM에는 OCI Domain으로의 outbound HTTPS 443과 AD DS로의 LDAP/LDAPS 389/636만 허용한다.
|
||||
|
||||
#### 적용 가이드: OCI AD Bridge와 AIPF OAuth client
|
||||
|
||||
이 절은 다음 환경에서 재적용할 때 사용하는 runbook이다. 명령의 secret 값은 출력하거나 Git에 넣지 않는다.
|
||||
|
||||
##### 1. 사전 점검
|
||||
|
||||
| 점검 | 기대값 | 실패 시 조치 |
|
||||
|---|---|---|
|
||||
| OCI Identity Domain | `identityAPAC` 등 대상 Domain이 `ACTIVE` | Domain 관리자 권한 및 Domain type/AD Bridge 허용량 확인 |
|
||||
| AD DS | `dds.test` Domain Controller와 DNS·LDAP 정상 | Bridge를 Domain-joined Windows에 설치할 수 있는지 확인 |
|
||||
| 네트워크 | Bridge VM → OCI Domain TCP/443, Bridge VM → AD TCP/636(LDAPS 권장) | NSG, Windows Firewall, proxy, AD 인증서 점검 |
|
||||
| AD service account | 동기화 OU 읽기 및 delegated authentication에 필요한 최소 attribute 권한 | Domain Admin을 상시 사용하지 말고 전용 service account 생성 |
|
||||
| AIPF callback | `https://aipf.cloud-handson.com/agentFactory/v1/tools/mcp/callback` 및 `/v1/tools/mcp/callback` | 두 URL 모두 OCI OAuth client redirect URI에 등록 |
|
||||
|
||||
현재 PoC의 Windows VM은 `hmm-ad-dss-test-isolated`이며 실행 중이다. public WinRM HTTPS(5986)의 OCI NSG, Windows listener, Windows Firewall을 구성해 외부 `/wsman` endpoint와 `opc@dds.test` 원격 실행을 확인했다. 따라서 Bridge client 설치는 WinRM으로 자동화할 수 있다. OCI Run Command는 command가 `ACCEPTED`에 머무르는 제약이 남아 있으므로 설치 수단으로 사용하지 않는다. 이 관리 경로 상태는 AD Bridge 자체의 요구사항이 아니라 설치 자동화 방식의 제약이다.
|
||||
|
||||
##### 2. OCI AD Bridge 생성과 Windows 설치
|
||||
|
||||
1. OCI Console에서 **Identity & Security → Domains → 대상 Domain → Directory integrations → Add → Microsoft Active Directory Bridge**로 이동한다.
|
||||
2. 화면에서 표시되는 **Identity Domain URL, Bridge client ID, Bridge client secret**을 안전한 password manager/secret store에 보관한다. 이것은 AIPF OAuth client secret 및 DB service client secret과 다른 값이다.
|
||||
3. Bridge client installer를 내려받아 domain-joined Windows에 설치한다. 현재 PoC artifact는 `ad-id-bridge-23.2.92-2301160723.exe`이며, checksum과 안전한 전달 방법은 [Cookbook의 installer artifact 관리](cookbook.md#11-installer-artifact-관리)를 따른다.
|
||||
4. 설치 UI에서 위 OCI Domain URL/client ID/client secret 및 AD Bridge service account를 입력하고 연결을 시험한다.
|
||||
5. AD 연결은 **LDAPS 사용**을 선택한다. AD DS 인증서가 Bridge Windows trust store에서 신뢰되지 않으면 먼저 CA/서버 인증서를 배포한다. 설치 후 SSL 선택은 변경이 어렵다.
|
||||
6. Directory integrations 화면에서 동기화할 사용자 OU, 그룹 OU를 최소 범위로 선택한다. `dds-alice`, `dds-bob`과 필요한 역할 그룹이 포함돼야 한다.
|
||||
7. Bridge initial sync 후 OCI Domain Users/Groups에 두 사용자가 나타나는지 확인한다.
|
||||
8. **Security → Delegated authentication**에서 `Test Delegated Authentication`으로 AD 사용자명과 AD 비밀번호를 시험하고 성공한 뒤에만 활성화한다.
|
||||
|
||||
`dds-alice`/`dds-bob` 로그인은 계속 AD 계정으로 한다. OCI Domain에 동기화된 user 레코드는 SSO/OIDC subject를 위한 cloud-side representation이고, delegated authentication 활성화 뒤의 비밀번호 검증 원천은 AD DS다.
|
||||
|
||||
##### 3. AIPF용 OCI OAuth client 생성
|
||||
|
||||
OCI Domain에는 DB service client와 별도로 AIPF용 confidential client가 필요하다. 이 PoC에서 만든 application 이름은 `AIPF_DDS_AD_TEST`이며 다음 정책을 사용한다.
|
||||
|
||||
| 항목 | 적용값 |
|
||||
|---|---|
|
||||
| Application type | Confidential OAuth client (`CustomWebAppTemplateId`) |
|
||||
| Grants | `authorization_code`, `refresh_token` |
|
||||
| Refresh token | 허용 (`allowOffline=true`) |
|
||||
| Redirect URI | AIPF callback 2개 경로 |
|
||||
| Access token expiry | 3600초 |
|
||||
| Refresh token expiry | 1209600초(14일) |
|
||||
| Consent | PoC에서 bypass; 운영 전 사용자 동의/정책 검토 |
|
||||
|
||||
OCI CLI로 생성할 경우 Identity Domain App API의 필수 `basedOnTemplate`을 반드시 포함한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:oracle:idcs:App"],
|
||||
"displayName": "AIPF_DDS_AD_TEST",
|
||||
"basedOnTemplate": {"value": "CustomWebAppTemplateId"},
|
||||
"active": true,
|
||||
"isOAuthClient": true,
|
||||
"clientType": "confidential",
|
||||
"allowedGrants": ["authorization_code", "refresh_token"],
|
||||
"redirectUris": [
|
||||
"https://aipf.cloud-handson.com/agentFactory/v1/tools/mcp/callback",
|
||||
"https://aipf.cloud-handson.com/v1/tools/mcp/callback"
|
||||
],
|
||||
"allowOffline": true
|
||||
}
|
||||
```
|
||||
|
||||
생성된 app ID/client ID/client secret은 local 검증 환경에서는 git-ignore된 `.runtime/aipf-dds-oci-ad-client.env`에만 저장한다. 파일에는 `OCI_AIPF_DDS_APP_ID`, `OCI_AIPF_DDS_CLIENT_ID`, `OCI_AIPF_DDS_CLIENT_SECRET`만 두며 값은 문서·Redmine·채팅·shell 출력에 기록하지 않는다.
|
||||
|
||||
##### 4. AIPF에 입력할 OCI OAuth 값
|
||||
|
||||
OCI OpenID discovery 문서에서 endpoint를 확인한다. URL을 임의로 조합하지 말고 아래 discovery endpoint의 실제 응답을 기준으로 한다.
|
||||
|
||||
```text
|
||||
https://<identity-domain>/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
현재 `identityAPAC`에서 확인한 형식은 다음과 같다.
|
||||
|
||||
| AIPF 입력 항목 | 값 형식 |
|
||||
|---|---|
|
||||
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp/dds/sse` |
|
||||
| Authentication mode | `OAuth` |
|
||||
| OAuth client ID/secret | `AIPF_DDS_AD_TEST` 생성 결과의 client ID/secret |
|
||||
| Authorization URL | `https://idcs-<domain>.identity.oraclecloud.com:443/oauth2/v1/authorize` |
|
||||
| Token / Refresh URL | `https://idcs-<domain>.identity.oraclecloud.com:443/oauth2/v1/token` |
|
||||
| Scopes | `openid profile email offline_access` (실제 app policy에 맞춰 조정) |
|
||||
|
||||
##### 5. MCP 및 DB 매핑 전환
|
||||
|
||||
Bridge와 AIPF OAuth login을 검증한 뒤, OCI access token을 decode하여 `iss`, `aud`, immutable `sub`를 기록한다. 그 다음에만 다음 값을 교체한다.
|
||||
|
||||
| 대상 | 변경 |
|
||||
|---|---|
|
||||
| HMM MCP `DDS_MCP_OIDC_ISSUER_URI` | OCI discovery가 반환한 issuer (현재 확인값 `https://identity.oraclecloud.com/`) |
|
||||
| HMM MCP `DDS_MCP_OIDC_AUDIENCE` | OCI user access token의 실제 `aud` 값 |
|
||||
| `CB_EXTERNAL_IDENTITY_BINDING` | OCI `iss + sub` → Alice/Bob application user 등록 |
|
||||
| AIPF MCP source | OCI OAuth client ID/secret/endpoint로 신규 source 생성 |
|
||||
|
||||
OCI token을 실제로 받은 뒤 audience를 정하는 이유는 client ID, resource scope, Domain 정책에 따라 access token claim이 달라질 수 있기 때문이다. 추측값으로 MCP를 먼저 재시작하면 기존 Keycloak 검증을 불필요하게 중단시킬 수 있다.
|
||||
|
||||
#### 적용 중 확인된 트러블슈팅 기록
|
||||
|
||||
| 증상/오류 | 원인 | 해결/판정 |
|
||||
|---|---|---|
|
||||
| `401 Authorization Required`, endpoint에 `/admin/v1/admin/v1/Apps` | `oci identity-domains ... --endpoint`에 `/admin/v1`까지 넣어 CLI가 경로를 중복 | CLI endpoint에는 Domain base URL만 넣는다. raw request일 때만 target URI에 `/admin/v1/...`를 넣는다. |
|
||||
| `No such option: --header` | `oci raw-request`는 `--header`가 아니라 `--request-headers` 사용 | `--request-headers '{"Content-Type":"application/json"}'`로 호출 |
|
||||
| `Missing required attribute(s): basedOnTemplate.` | OCI App API는 OAuth client 생성 시 template 필수 | `"basedOnTemplate":{"value":"CustomWebAppTemplateId"}` 추가 |
|
||||
| authorization endpoint가 `302`가 아니라 `HTTP 200 OK` | OCI Domain이 redirect 대신 로그인 HTML과 secure session cookie를 반환 | 정상. browser에서 OCI 로그인 화면이 보이면 OAuth preflight 통과로 판정 |
|
||||
| OCI console browser automation 불가 | 현재 작업 환경에 browser binding 없음 | OCI Console 화면 조작이 필요한 Bridge 생성은 RDP/관리자 Console session으로 수행; API로 가능한 OAuth app/MCP 설정은 자동화 계속 |
|
||||
| Windows VM WinRM 5986 `Connection refused` | WinRM HTTPS listener/Windows Firewall/NSG 미구성 | RDP 설치를 사용하거나 WinRM HTTPS를 별도 구성. Bridge 기능의 오류로 해석하지 않음 |
|
||||
| OCI Compute Instance Run Command plugin은 `RUNNING`인데 명령이 계속 `ACCEPTED` / `VISIBLE` | plugin 상태 보고는 가능하지만 Windows agent가 command execution을 수신·실행하지 못함. PowerShell preflight와 단순 `echo` 모두 동일 | Bridge installer 실행 수단으로 사용하지 않는다. Windows의 Oracle Cloud Agent/Run Command service, outbound OCI connectivity, plugin 로그를 RDP에서 점검한 뒤 재시도한다. |
|
||||
| AD Bridge test가 LDAPS certificate 오류 | AD DS 인증서 체인이 Bridge host trust store에 없음 | 사내 CA/AD 인증서를 Bridge host에 신뢰시키고 LDAPS를 유지 |
|
||||
| OCI OAuth login은 성공하지만 MCP `401` | MCP issuer/audience가 Keycloak 값 그대로이거나 OCI `sub` binding 없음 | OCI JWT claim 확인 후 MCP env와 `CB_EXTERNAL_IDENTITY_BINDING`을 함께 교체 |
|
||||
| AIPF source에서 이전 사용자로 로그인 | OCI Domain browser SSO session 유지 | OCI Domain logout 후 source 재연결; AIPF source 저장 token과 browser SSO session을 구분 |
|
||||
|
||||
#### 전환 검증 기준
|
||||
|
||||
| 단계 | 증거 | 성공 기준 |
|
||||
|---|---|---|
|
||||
| AD Bridge 연결 | Bridge 상태 `Connected`, AD 사용자/그룹 동기화 결과 | `dds-alice`, `dds-bob`이 OCI Domain에 존재 |
|
||||
| delegated authentication | OCI Domain의 test delegated authentication | 두 사용자의 AD 비밀번호로 성공 |
|
||||
| OAuth code flow | AIPF가 OCI login → callback → MCP source 연결 완료 | OCI issuer의 access token이 저장됨 |
|
||||
| MCP 검증 | OCI JWT의 signature/issuer/audience 통과 | `iss + sub` mapping 조회 성공 |
|
||||
| DDS 권한 | 같은 `휴가` tool 호출 | Alice 1건, Bob default deny |
|
||||
| 회귀/보안 | Keycloak token, 미매핑 OCI token, 만료 token | DB query 전 거부 |
|
||||
|
||||
### 이 설계가 답하는 질문
|
||||
|
||||
이 PoC는 다음 네 가지를 의도적으로 분리한다.
|
||||
|
||||
| 질문 | 답 | 담당 구성요소 |
|
||||
|---|---|---|
|
||||
| 직원 계정과 비밀번호는 어디에 있는가? | Windows AD DS가 원천이다. | AD DS |
|
||||
| 사용자가 MCP에 로그인했음을 어떻게 증명하는가? | Keycloak이 AD LDAP 인증을 거쳐 OIDC access token(JWT)을 발급한다. | Keycloak |
|
||||
| MCP 서버가 Oracle DB에 접속해 DDS context를 열 자격은 어떻게 얻는가? | OCI Identity Domain의 confidential service client가 database-access token을 발급받는다. | OCI IAM integrated application / credential app |
|
||||
| Alice와 Bob이 서로 다른 데이터만 보게 하는 최종 판단은 누가 하는가? | Oracle Deep Sec이 요청별 local DDS END USER와 DATA GRANT를 적용한다. | Oracle Database |
|
||||
|
||||
즉, **AD 계정으로 로그인한 사용자 토큰**과 **MCP 서버가 DB에 접속하는 서비스 토큰**은 목적과 발급자가 다른 별개의 token이다. 전자는 “누가 요청했는가”를, 후자는 “어떤 애플리케이션이 DB context를 열 수 있는가”를 증명한다. 하나를 다른 하나의 대체물로 쓰지 않는다.
|
||||
|
||||
## 2. 격리 인프라
|
||||
|
||||
| 항목 | 구성 |
|
||||
|---|---|
|
||||
| 네트워크 | `handson-vcn`의 전용 `10.0.2.0/28` public subnet |
|
||||
| Windows VM | `hmm-ad-dss-test-isolated`, Windows Server 2022, 2 OCPU / 16 GB |
|
||||
| 관리 접근 | RDP TCP/3389은 작업자 공인 IP 한 곳에만 NSG로 허용 |
|
||||
| OIDC HTTPS | `https://ad.cloud-handson.com` (Caddy TLS reverse proxy → Keycloak) |
|
||||
| 서브넷 보안 목록 | 인바운드 전부 차단, Windows Update 및 테스트용 아웃바운드만 허용 |
|
||||
| 도메인 | `dds.test` (AD DS forest/domain) |
|
||||
|
||||
이 VM은 테스트 전용이다. 기존 공유 public subnet의 NAT 경로를 변경하지 않으며, 비용 발생 리소스이므로 검증 종료 뒤 중지 또는 삭제를 결정한다.
|
||||
|
||||
## 3. 식별자와 권한 흐름
|
||||
|
||||
```text
|
||||
AD 사용자 (UPN: alice@dds.test, objectGUID)
|
||||
│ LDAP / OIDC bridge
|
||||
▼
|
||||
검증된 Bearer claims
|
||||
iss, aud, exp, sub = 불변 외부 subject
|
||||
│ MCP bearer authenticator
|
||||
▼
|
||||
CB_EXTERNAL_IDENTITY_BINDING
|
||||
issuer + subject -> CB_APP_USER.user_id
|
||||
▼
|
||||
CB_DDS_END_USER_MAP
|
||||
user_id -> DDS_U_<user_id> local END USER
|
||||
▼
|
||||
ORA_END_USER_CONTEXT + DDS DATA GRANT / 권한 함수
|
||||
```
|
||||
|
||||
`sub`는 Keycloak이 발급하는 안정적인 federated-user 식별자다. AD objectGUID는 원천 디렉터리의 감사 식별자로 유지하되, 실제 권한 키는 **검증된 JWT의 `iss + sub`**로 고정한다. UPN은 로그인 화면·감사 표시용으로 보관할 수 있지만 권한 키로 신뢰하지 않는다. `iss + sub` 조합이 유일하지 않거나, 매핑이 없거나, 사용자가 비활성이면 요청은 fail-closed로 거부한다.
|
||||
|
||||
### 전체 요청 흐름
|
||||
|
||||
```text
|
||||
(1) 사용자가 AIPF에서 dds MCP를 연결
|
||||
│ OAuth authorization code flow
|
||||
▼
|
||||
(2) Keycloak 로그인 화면
|
||||
│ AD LDAP bind: dds-alice / dds-bob의 비밀번호 확인
|
||||
▼
|
||||
(3) Keycloak access token 발급
|
||||
│ iss, aud=dds-mcp, exp, sub 포함 / Keycloak signing key로 서명
|
||||
▼
|
||||
(4) AIPF가 MCP 요청에 Bearer access token을 첨부
|
||||
▼
|
||||
(5) MCP
|
||||
│ Keycloak JWKS로 서명·issuer·audience·만료 검증
|
||||
│ CB_EXTERNAL_IDENTITY_BINDING에서 iss+sub 조회
|
||||
▼
|
||||
(6) MCP가 OCI IAM service token을 별도로 획득
|
||||
│ client credentials: DDS_MCP_SERVICE_TEST
|
||||
▼
|
||||
(7) Oracle DB
|
||||
│ service token으로 trusted application identity 확인
|
||||
│ 조회한 DDS_U_n end-user context attach
|
||||
▼
|
||||
(8) Deep Sec
|
||||
│ DDS_U_n의 DATA ROLE/DATA GRANT만 적용하여 SQL 실행
|
||||
▼
|
||||
(9) MCP가 허용된 결과만 AIPF로 반환
|
||||
```
|
||||
|
||||
MCP는 이 흐름의 **정책 집행점**이다. 사용자의 JWT를 검증하지 못하면 5단계에서 멈추고, DB service token이 유효하지 않으면 7단계에서 멈추며, 둘 다 통과해도 해당 DDS END USER의 grant가 없으면 8단계에서 멈춘다. 어느 단계에서도 실패를 우회하여 공유 DB 계정의 광범위한 권한으로 조회해서는 안 된다.
|
||||
|
||||
### 사용자 인증과 DB 접속 신뢰의 분리
|
||||
|
||||
이 PoC에는 서로 다른 두 신뢰 체인이 있다. 둘을 같은 OAuth token으로 혼동하지 않는다.
|
||||
|
||||
```text
|
||||
[업무 사용자 인증]
|
||||
AD user -> Keycloak (AD LDAP 인증) -> Keycloak JWT
|
||||
-> MCP validates iss + sub -> DDS_U_1 / DDS_U_2 선택
|
||||
|
||||
[DB 접속 신뢰]
|
||||
MCP service -> OCI IAM DDS_MCP_SERVICE_TEST (client credentials)
|
||||
-> database-access token -> Oracle DB
|
||||
-> 선택된 local DDS END USER context attach
|
||||
```
|
||||
|
||||
| 역할 | 현재 담당 | 하는 일 |
|
||||
|---|---|---|
|
||||
| AD 사용자 인증 | Keycloak | AD LDAP의 계정·비밀번호를 확인하고 Keycloak JWT를 발급 |
|
||||
| 업무 사용자 식별 | MCP | 검증된 Keycloak `iss + sub`를 HMM application user와 local DDS END USER에 매핑 |
|
||||
| DB 접속 애플리케이션 신뢰 | OCI IAM credential app | MCP service가 database-access token을 받아 Oracle DB에 신뢰된 application임을 증명 |
|
||||
| 최종 데이터 권한 | Oracle Deep Sec | attach된 local DDS END USER의 DATA ROLE과 DATA GRANT로 행·열을 제한 |
|
||||
|
||||
따라서 OCI IAM credential app은 현재 AD 사용자를 직접 로그인시키지 않는다. OCI IAM은 MCP service의 database-access token 발급자이며, Oracle DB가 해당 service client를 application identity로 신뢰하게 한다. AD 사용자가 어떤 DDS END USER가 되는지는 MCP가 결정한다.
|
||||
|
||||
향후 OCI IAM을 사용자 인증의 중심으로 바꾸려면 Keycloak을 OCI IAM의 외부 IdP(OIDC 또는 SAML)로 federation하고, AIPF/MCP가 OCI IAM user token을 받도록 변경한다. 그 경우 Oracle DB도 OCI IAM user token의 issuer·group claim을 직접 검증할 수 있다. 현재 PoC의 local DDS END USER 매핑과는 별도의 확장 경로다.
|
||||
|
||||
### OCI Identity Domain integrated application을 쓰는 이유와 역할
|
||||
|
||||
여기서 말하는 OCI Domain integrated application은 `DDS_ORACLE_DB_TEST` database resource application이다. 이를 사용자용 웹 로그인 앱으로 오해하면 안 된다. 이 앱은 **Oracle Autonomous Database가 신뢰할 OAuth audience와 scope를 OCI Identity Domain에 선언하는 등록물**이다.
|
||||
|
||||
일반 OAuth access token은 “어느 resource server를 위한 token인지”가 불명확하면 DB가 받아들여서는 안 된다. integrated application은 DB용 resource identity를 만들고, `DB_ACCESS_SCOPE` 같은 전용 scope를 발급 정책에 묶는다. 그러면 DB는 다음 세 가지가 일치할 때만 MCP의 service token을 받아들인다.
|
||||
|
||||
| DB가 확인하는 값 | integrated application에서 정하는 값 | 이 PoC의 의미 |
|
||||
|---|---|---|
|
||||
| resource application | `DDS_ORACLE_DB_TEST` | 이 token의 수신자가 DDS 대상 Oracle DB임을 식별 |
|
||||
| scope | `DB_ACCESS_SCOPE` | DB context 접근이라는 최소 권한을 명시 |
|
||||
| issuer | OCI Identity Domain URL | token을 발급하고 서명키를 제공하는 신뢰 경계 |
|
||||
| OAuth client ID | `DDS_MCP_SERVICE_TEST` | token을 요청한 MCP service를 식별 |
|
||||
|
||||
`DDS_MCP_SERVICE_TEST`는 integrated application 자체가 아니라, 그 resource/scope를 요청할 수 있도록 허가된 **confidential service client**다. client secret을 가진 백엔드 MCP만 client credentials flow로 token을 발급받는다. 브라우저·AIPF·AD 사용자는 이 secret이나 service token을 보거나 보관하지 않는다.
|
||||
|
||||
```text
|
||||
OCI Identity Domain
|
||||
|
||||
DDS_ORACLE_DB_TEST DDS_MCP_SERVICE_TEST
|
||||
(resource / integrated app) (confidential OAuth client)
|
||||
┌───────────────────────┐ ┌─────────────────────────┐
|
||||
│ audience: Oracle DB │<--scope--│ client credentials only │
|
||||
│ scope: DB_ACCESS_SCOPE│ │ secret: MCP host only │
|
||||
└──────────┬────────────┘ └───────────┬─────────────┘
|
||||
│ database-access token │ token request
|
||||
└────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Autonomous DB application identity
|
||||
```
|
||||
|
||||
이 구조를 쓰는 이유는 DB password를 MCP에 장기 보관하거나, 모든 사용자에게 DB 로그인 권한을 주지 않기 위해서다. service client는 DB context를 여는 최소 권한만 보유하고, 사람별 데이터 권한은 local DDS END USER에 남긴다. 서비스 client secret이 노출되더라도 해당 client에 부여하지 않은 데이터 권한이 자동으로 생기는 구조가 아니다. 다만 token 발급과 DB context 생성 자체를 악용할 수 있으므로 secret은 즉시 회전하고, 이 client에는 불필요한 OCI 권한을 부여하지 않는다.
|
||||
|
||||
### OCI IAM과 Keycloak을 함께 쓰는 이유
|
||||
|
||||
현재 AD DS는 LDAP/Kerberos 디렉터리이지 인터넷 서비스가 직접 검증할 OAuth JWT issuer는 아니다. Keycloak은 AD LDAP과 OIDC 사이의 번역 계층이다. 반면 OCI IAM integrated application은 Oracle DB가 지원하는 database-access token contract를 제공한다.
|
||||
|
||||
| 경계 | 입력 | 출력 | 선택 이유 |
|
||||
|---|---|---|---|
|
||||
| AD DS → Keycloak | AD 사용자명/비밀번호, LDAP 사용자·그룹 | Keycloak OIDC JWT | AD를 계정 원천으로 유지하면서 표준 OAuth/OIDC를 제공 |
|
||||
| Keycloak → MCP | Keycloak JWT/JWKS | 검증된 `iss + sub` | MCP가 사용자 identity를 안전하게 해석 |
|
||||
| MCP → OCI IAM | service client ID/secret | DB 전용 access token | DB가 신뢰할 service identity 증명 |
|
||||
| OCI IAM → Oracle DB | database-access token | application identity | DB password 없이 DB context 접근을 제한 |
|
||||
| Oracle DB → DDS | local END USER | 필터된 query 결과 | 데이터 권한을 DB 내부에서 강제 |
|
||||
|
||||
따라서 “OCI Domain credential app이 AD를 신뢰한다”는 표현은 현재 PoC에는 정확하지 않다. 현재는 **Keycloak이 AD를 신뢰해 사용자 인증을 수행**하고, **Oracle DB가 OCI IAM을 신뢰해 MCP service를 인증**한다. 두 체인은 MCP 안에서 만나며, MCP가 Keycloak token의 subject를 local DDS END USER로 결정한다.
|
||||
|
||||
## 4. 구현 단계
|
||||
|
||||
1. Windows Server에 AD DS를 설치하고 `dds.test` forest를 생성한다.
|
||||
2. 테스트 사용자 `dds-alice`, `dds-bob`와 권한 그룹을 만들고, LDAP 조회와 인증을 확인한다.
|
||||
3. AD LDAP과 연동한 OIDC bridge(예: Keycloak)를 별도 서비스로 구성한다. bridge가 내는 JWT의 `iss`, `aud`, `exp`, JWKS 서명을 MCP가 검증한다.
|
||||
4. `issuer + sub`에서 `CB_APP_USER` 및 `CB_DDS_END_USER_MAP`을 해석하도록 백오피스/DB 매핑을 추가한다.
|
||||
5. alice/bob의 서로 다른 `DDS_U_*` context에서 동일 MCP tool 호출 결과가 권한에 따라 달라지는지 검증한다.
|
||||
6. 매핑 누락, 만료 토큰, 다른 issuer/audience, 비활성 계정은 모두 거부되는 regression을 추가한다.
|
||||
|
||||
## 4.1 현재 테스트 bridge
|
||||
|
||||
`database/adb/47_dds_ad_identity_test_setup.sql`은 HMM DB의 기존 업무 사용자 원천을 변경하지 않는다. 스크립트가 만든 `CB_EXTERNAL_IDENTITY_BINDING`에는 최종 OIDC issuer와 Keycloak이 실제로 발급한 subject만 보관한다.
|
||||
|
||||
| AD 사용자 | OIDC issuer | JWT `sub` | HMM application user | DDS END USER |
|
||||
|---|---|---|---:|---|
|
||||
| `dds-alice` | `https://ad.cloud-handson.com/realms/dds-test` | `11cf09c9-4713-4145-85cd-e3397d4a2405` | 1 | `DDS_U_1` |
|
||||
| `dds-bob` | `https://ad.cloud-handson.com/realms/dds-test` | `d9610e18-a327-4dda-9496-69e5a2067460` | 2 | `DDS_U_2` |
|
||||
|
||||
`dds-mcp` client는 access token audience에 `dds-mcp`를 포함한다. MCP는 Keycloak JWKS에서 JWT 서명과 `iss`, `aud`, 만료 시간을 검증한 뒤에만 위 매핑을 조회한다. 실제 `/dds/mcp/messages` 초기화 요청에 Alice JWT를 넣어 성공 응답을 확인했다.
|
||||
|
||||
초기 게시 단계에서는 END USER와 DATA ROLE만 만들고 default deny로 시작한다. 현재 테스트에서는 Alice에만 vector view SELECT data grant를 추가했고 Bob은 grant 없이 유지한다.
|
||||
|
||||
### Keycloak 구성에서 중요한 값
|
||||
|
||||
Keycloak realm `dds-test`은 AD LDAP user federation을 사용한다. 로그인 화면에서 입력한 `dds-alice` 또는 `dds-bob`의 비밀번호 검증은 AD DS가 담당하며, Keycloak은 성공한 LDAP 사용자의 immutable identity를 자신의 `sub`로 유지해 JWT에 넣는다. AD 비밀번호나 LDAP bind credential은 MCP와 AIPF에 전달되지 않는다.
|
||||
|
||||
| Keycloak 항목 | 값/정책 | 필요한 이유 |
|
||||
|---|---|---|
|
||||
| Realm issuer | `https://ad.cloud-handson.com/realms/dds-test` | MCP의 허용 issuer와 identity binding의 namespace |
|
||||
| MCP audience | `dds-mcp` | 다른 Keycloak client용 token의 재사용 방지 |
|
||||
| AIPF confidential client | `aipf-dds-mcp` | AIPF OAuth callback과 token exchange 수행 |
|
||||
| MCP resource client | `dds-mcp` | access token audience 검증 대상 |
|
||||
| Redirect URI | AIPF callback 두 경로와 wildcard 등록 | authorization code를 허용된 AIPF에만 반환 |
|
||||
| PKCE | AIPF client에서 미사용 | 현재 AIPF가 `code_challenge_method`를 보내지 않기 때문; 향후 AIPF 지원 시 S256으로 전환 |
|
||||
| Direct grant | 테스트 client에서만 제한적으로 사용 | CLI 검증용; 운영 browser client에는 사용하지 않음 |
|
||||
|
||||
JWT 검증은 서명만 확인하는 것으로 충분하지 않다. MCP는 최소한 다음을 모두 확인해야 한다.
|
||||
|
||||
1. `alg`가 허용된 비대칭 서명 알고리즘인지 확인하고, Keycloak JWKS의 해당 `kid`로 signature를 검증한다.
|
||||
2. `iss`가 realm issuer와 정확히 같은지 확인한다. URL의 realm·host가 다른 token은 거부한다.
|
||||
3. `aud`에 `dds-mcp`가 포함됐는지 확인한다.
|
||||
4. `exp`, `nbf`, `iat` 및 허용 clock skew를 검사한다.
|
||||
5. 검증된 `iss + sub`로만 `CB_EXTERNAL_IDENTITY_BINDING`을 조회한다. browser가 표시한 email, username, group 문자열만으로 매핑하지 않는다.
|
||||
|
||||
Keycloak signing key rotation 시 MCP의 JWKS cache가 새 `kid`를 다시 조회할 수 있어야 한다. issuer나 realm을 바꾸면 binding table의 issuer 값과 MCP allow-list도 함께 변경해야 한다.
|
||||
|
||||
### 테스트 Keycloak 사용자 비밀번호를 얻는 방법
|
||||
|
||||
**실제 로그인 인증 원천은 Windows AD DS다.** `dds-alice`와 `dds-bob`은 Keycloak에 로컬로 만든 계정이 아니라 AD DS의 테스트 사용자다. AIPF가 Keycloak 로그인 화면으로 이동하면 사용자는 AD 사용자명과 AD 비밀번호를 입력하고, Keycloak은 LDAP을 통해 AD에 비밀번호를 확인한다. AD 인증이 성공할 때에만 Keycloak이 OIDC JWT를 발급한다.
|
||||
|
||||
```text
|
||||
사용자: dds-alice + AD 비밀번호 입력
|
||||
│
|
||||
▼
|
||||
Keycloak ── LDAP 인증 요청 ──► Windows AD DS
|
||||
│ │
|
||||
└── 인증 성공 ◄─────┘
|
||||
│
|
||||
▼
|
||||
Keycloak OIDC access token 발급
|
||||
```
|
||||
|
||||
`.runtime/dds-ad-test-users.env`는 인증 서버나 Keycloak 설정이 아니다. 테스트 중 AD 비밀번호를 안전하게 다시 입력할 수 있도록 **현재 AD에 설정된 테스트 비밀번호를 로컬에서 참고하는 git-ignore 파일**일 뿐이다. AD에서 비밀번호를 변경하면 실제 로그인에는 새 AD 비밀번호가 즉시 적용되며, 이 파일은 참고값으로만 함께 갱신한다. 이 비밀번호는 OAuth client secret이나 OCI IAM service client secret과 전혀 다른 값이다.
|
||||
|
||||
실제 값은 저장소·문서·채팅에 기록하지 않는다. 현재 작업 환경에서는 git-ignore된 권한 제한 파일 `.runtime/dds-ad-test-users.env`에 보관한다. 다음 명령은 비밀번호를 터미널에 표시하지 않고 macOS 클립보드에 복사한다.
|
||||
|
||||
```bash
|
||||
# dds-alice 비밀번호 복사
|
||||
sed -n 's/^DDS_ALICE_PASSWORD=//p' .runtime/dds-ad-test-users.env | pbcopy
|
||||
|
||||
# dds-bob 비밀번호 복사
|
||||
sed -n 's/^DDS_BOB_PASSWORD=//p' .runtime/dds-ad-test-users.env | pbcopy
|
||||
```
|
||||
|
||||
명령 출력이 없는 것이 정상이다. 원하는 사용자 항목을 실행한 다음 Keycloak 로그인 화면의 password 칸에 `⌘V`로 붙여넣는다. 로그인 ID는 각각 `dds-alice`, `dds-bob`이며, 도메인 표기가 필요한 Windows/LDAP 화면에서는 `DDS\\dds-alice`, `DDS\\dds-bob`을 사용한다. `pbcopy`가 올바른 명령이며 `pbcop`는 오타다.
|
||||
|
||||
파일이 없거나 비밀번호가 맞지 않으면 값 추측이나 문서 검색을 하지 않는다. AD 관리자에게 test-user password reset을 요청하고, reset 후 이 권한 제한 파일만 갱신한다. user password reset은 AIPF OAuth client secret이나 OCI IAM service client secret rotation을 의미하지 않는다.
|
||||
|
||||
## 4.2 AIPF MCP 등록 값
|
||||
|
||||
AIPF의 **Edit MCP server** 화면에서 다음 값으로 등록한다. AIPF는 confidential OAuth client를 요구하므로, 일반 테스트용 public client `dds-mcp`가 아니라 AIPF 전용 client `aipf-dds-mcp`를 사용한다.
|
||||
|
||||
| AIPF 항목 | 입력값 |
|
||||
|---|---|
|
||||
| Server name | `alice-dds` (Bob 검증 때는 `bob-dds`처럼 별도 등록) |
|
||||
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp/dds/sse` |
|
||||
| Authentication mode | `OAuth` |
|
||||
| Redirect callback URL | AIPF 표시값 `https://aipf.cloud-handson.com/agentFactory/v1/tools/mcp/callback` — 입력하지 않는 읽기 전용 값 |
|
||||
| OAuth client ID | `aipf-dds-mcp` |
|
||||
| OAuth client secret | 운영자 보관 secret. 저장소·문서·대화에 기록하지 않는다. 현재 작업 환경에서는 `.runtime/aipf-dds-mcp-client.env`의 `OAUTH_CLIENT_SECRET` 값을 사용한다. |
|
||||
| Authorization URL | `https://ad.cloud-handson.com/realms/dds-test/protocol/openid-connect/auth` |
|
||||
| Token URL | `https://ad.cloud-handson.com/realms/dds-test/protocol/openid-connect/token` |
|
||||
| Refresh URL | `https://ad.cloud-handson.com/realms/dds-test/protocol/openid-connect/token` |
|
||||
| Scopes | `openid profile email` |
|
||||
|
||||
### OAuth client secret을 얻는 방법
|
||||
|
||||
여기서 필요한 secret은 AD 사용자 비밀번호가 아니라 Keycloak의 `aipf-dds-mcp` OAuth client secret이다. secret은 저장소에 커밋하거나 문서·대화에 적지 않는다. 현재 작업 환경에서는 권한 제한 파일에 보관하며, 다음 명령으로 **값을 화면에 표시하지 않고** macOS 클립보드에 복사한다.
|
||||
|
||||
```bash
|
||||
sed -n 's/^OAUTH_CLIENT_SECRET=//p' .runtime/aipf-dds-mcp-client.env | pbcopy
|
||||
```
|
||||
|
||||
명령 출력이 없는 것이 정상이다. 이후 AIPF의 **OAuth client secret** 입력칸에 `⌘V`로 붙여넣고 저장한다. 복사 명령은 `pbcopy`이며 `pbcop`가 아니다. 파일의 secret 값은 Keycloak에서 OAuth client를 재생성하거나 rotation한 경우에만 다시 동기화한다.
|
||||
|
||||
### AIPF callback 호환 보정
|
||||
|
||||
현재 AIPF는 화면에 표시하는 callback과 달리 OAuth authorization request에는 `/agentFactory`가 빠진 `https://aipf.cloud-handson.com/v1/tools/mcp/callback`을 보낸다. AIPF Nginx는 이 경로를 같은 origin의 실제 callback으로 302 전환하도록 구성했다.
|
||||
|
||||
```text
|
||||
/v1/tools/mcp/callback
|
||||
-> /agentFactory/v1/tools/mcp/callback
|
||||
```
|
||||
|
||||
이 전환은 OAuth `code`와 `state`를 보존하고 브라우저가 `/agentFactory` 범위의 AIPF 세션 쿠키를 다시 전송하게 한다. AIPF의 OAuth callback 생성 로직이 수정되면 이 Nginx 보정은 제거할 수 있다.
|
||||
|
||||
### AIPF 로그인 상태와 MCP 등록을 구분할 것
|
||||
|
||||
AIPF의 MCP 등록 정보와 Keycloak의 브라우저 SSO 세션은 서로 다르다.
|
||||
|
||||
| 상태 | 보관 위치 | 영향 |
|
||||
|---|---|---|
|
||||
| MCP OAuth client ID/secret, 연결 설정 | AIPF의 MCP source 설정 | 최초 OAuth 연결 및 refresh token 교환에 사용 |
|
||||
| Keycloak SSO 로그인 세션 | 브라우저의 `ad.cloud-handson.com` cookie | 다음 OAuth 연결 시 이전 사용자로 자동 로그인될 수 있음 |
|
||||
| AIPF가 저장한 MCP token/refresh token | AIPF backend source별 저장소 | 등록한 MCP source가 이후 tool 호출할 때 사용 |
|
||||
|
||||
그러므로 Alice에서 Bob을 시험할 때는 source를 `bob-dds`로 새로 만들거나 기존 연결을 끊고, Keycloak logout을 먼저 수행한다. 같은 browser session에서 바로 다시 연결하면 AIPF 설정을 Bob으로 바꿔도 Keycloak SSO가 Alice를 다시 인증할 수 있다. Keycloak logout endpoint는 다음과 같다.
|
||||
|
||||
```text
|
||||
https://ad.cloud-handson.com/realms/dds-test/protocol/openid-connect/logout
|
||||
```
|
||||
|
||||
새 AIPF source에는 OAuth client ID와 secret을 다시 입력한다. source 간에 인증정보가 자동 상속된다고 가정하지 않는다.
|
||||
|
||||
## 4.3 테스트 시나리오
|
||||
|
||||
### A. Alice 인증과 MCP 연결
|
||||
|
||||
1. AIPF에서 위 값으로 `alice-dds`를 저장하고 연결한다.
|
||||
2. Keycloak 로그인 화면에서 AD 사용자 `dds-alice`로 로그인한다. 도메인 표기가 필요하면 `DDS\\dds-alice`를 사용한다.
|
||||
3. AIPF가 callback으로 돌아오면 `alice-dds` MCP 연결이 완료된다.
|
||||
4. AIPF Agent 대화에서 다음 요청을 실행한다.
|
||||
|
||||
```text
|
||||
dds_vector_search 도구를 사용해서 "휴가"를 검색해줘.
|
||||
embeddingMode는 DEMO, limit은 10으로 해줘.
|
||||
```
|
||||
|
||||
5. MCP는 Keycloak access token의 검증된 `iss + sub`를 Alice의 application user와 `DDS_U_1`에 매핑한 뒤 `dds_vector_search`를 호출한다.
|
||||
|
||||
### B. Bob 권한 비교
|
||||
|
||||
1. AIPF OAuth 연결을 끊거나 새 MCP 등록 `bob-dds`를 만든다.
|
||||
2. 같은 OAuth 설정으로 연결한 뒤 AD 사용자 `dds-bob`으로 로그인한다.
|
||||
3. Alice와 동일한 검색 요청을 실행한다.
|
||||
4. DDS data grant 구성 후 기대 결과는 다음과 같다.
|
||||
|
||||
| 사용자 | JWT subject 매핑 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| `dds-alice` | application user `1` → `DDS_U_1` | 허용된 지식 행만 반환 |
|
||||
| `dds-bob` | application user `2` → `DDS_U_2` | 빈 결과 또는 DDS 권한 거부 |
|
||||
|
||||
### C. 판정 기준과 현 상태
|
||||
|
||||
| 확인 항목 | 판정 방법 | 현재 상태 |
|
||||
|---|---|---|
|
||||
| OAuth 로그인 | AIPF가 Keycloak 로그인 후 MCP 등록 화면으로 복귀 | 확인 완료 |
|
||||
| JWT 검증/매핑 | `tools/list` 또는 `dds_vector_search`가 Bearer 토큰 검증을 통과 | 확인 완료 |
|
||||
| Alice/Bob 데이터 차이 | 같은 `dds_vector_search` 호출에서 반환 행이 다름 | 확인 완료 — Alice 1건 반환, Bob default deny |
|
||||
| 미매핑 토큰 거부 | 매핑 없는 `iss + sub`의 MCP 호출이 `AUTHORIZATION_DENIED` | 구현 완료, 회귀 검증 대기 |
|
||||
|
||||
`dds_vector_search`는 현재 유일한 DDS MCP 도구이며 입력값은 `query`(필수), `limit`(1~100), `embeddingMode`(`DEMO` 또는 `AI`)다. service identity와 Alice 허용/Bob 거부 DDS grant를 반영해 권한 차이까지 검증했다.
|
||||
|
||||
## 4.4 OCI IAM database-access token 구성
|
||||
|
||||
Keycloak access token은 MCP의 업무 사용자 식별과 `iss + sub` 매핑에만 사용한다. Oracle Deep Sec context를 열 때는 별도의 OCI IAM database-access token이 필요하다. 이 토큰은 `DDS_MCP_SERVICE_TEST` confidential client가 client credentials flow로 받고, DB는 해당 client ID를 application identity로 신뢰한다.
|
||||
|
||||
```text
|
||||
AD user -> Keycloak token -> MCP user mapping -> local DDS END USER
|
||||
\
|
||||
OCI IAM DDS_MCP_SERVICE_TEST -- database-access token --> Oracle Deep Sec context
|
||||
```
|
||||
|
||||
구성 순서는 OCI IAM database resource(`DDS_ORACLE_DB_TEST`)와 scope(`DB_ACCESS_SCOPE`) 생성, OCI IAM service client 생성, Autonomous DB의 OCI IAM identity provider/credential 등록, 그리고 `CREATE APPLICATION IDENTITY ... MAPPED TO 'IAM_OAUTH_CLIENT_ID=...'` 순서다. 이 단계는 Autonomous DB의 외부 인증 구성을 변경할 수 있으므로 기존 설정을 먼저 조회하고 Redmine에 기록한다.
|
||||
|
||||
### 실제 구성 절차와 신뢰 등록 방향
|
||||
|
||||
다음 순서는 “누가 누구를 신뢰하도록 등록하는가”를 기준으로 작성했다. secret, client ID, application ID는 환경별 값이므로 이 문서에 넣지 않고 권한 제한 환경 파일 또는 OCI console에서 관리한다.
|
||||
|
||||
1. OCI Identity Domain에서 database resource/integrated application `DDS_ORACLE_DB_TEST`를 만들고 `DB_ACCESS_SCOPE`를 정의한다. 이것이 DB가 받아들일 audience와 scope의 계약이다.
|
||||
2. 같은 Domain에서 confidential client `DDS_MCP_SERVICE_TEST`를 만든다. grant type은 `client_credentials`만 허용하고, 1단계의 DB scope만 허용한다. 발급된 client secret은 MCP host의 mode 600 환경 파일에만 저장한다.
|
||||
3. Autonomous DB에 OCI IAM external authentication을 활성화하고, Domain URL 및 database resource application ID를 등록한다. DB의 issuer URL은 token `iss`와 정규형까지 일치해야 한다. 이 환경에서는 HTTPS `:443`을 포함한다.
|
||||
4. DB에 OCI IAM signing-key credential을 만들고, `DDS_MCP_SERVICE_TEST`의 OAuth client ID를 DB application identity에 매핑한다. 이 단계로 DB는 “이 client credentials로 발급된 DB token을 가진 서비스”를 신뢰한다.
|
||||
5. HMM MCP runtime에 service client ID, secret, token endpoint, scope, resource 대상 view를 주입하고 서비스 계정을 재시작한다. 사용자 access token이나 AD 비밀번호를 이 파일에 넣지 않는다.
|
||||
6. 별도로 Keycloak `iss + sub` → `CB_APP_USER` → `CB_DDS_END_USER_MAP`을 등록한다. `DDS_U_1_ROLE` 같은 local role에 대상 객체 DATA GRANT를 필요한 사용자에게만 부여한다.
|
||||
|
||||
신뢰의 방향은 아래와 같다.
|
||||
|
||||
```text
|
||||
AD DS ──(LDAP credential 확인)──► Keycloak
|
||||
Keycloak ──(signed user JWT)────► MCP
|
||||
MCP ──(client credentials)──────► OCI Identity Domain
|
||||
OCI Identity Domain ──(signed DB token)──► Oracle DB
|
||||
Oracle DB ──(DDS grant 평가)────► 데이터
|
||||
```
|
||||
|
||||
Oracle DB는 AD DS나 Keycloak을 직접 신뢰하도록 등록되어 있지 않다. 반대로 OCI IAM integrated application도 AD 사용자 credential을 검증하지 않는다. MCP가 두 신뢰 체인의 검증 결과를 결합하는 위치다.
|
||||
|
||||
### MCP 런타임에서 token을 사용하는 방식
|
||||
|
||||
MCP 요청을 처리할 때 서비스는 두 token을 다음 순서로 취급한다.
|
||||
|
||||
| 순서 | token | MCP가 하는 일 | 실패하면 |
|
||||
|---:|---|---|---|
|
||||
| 1 | Keycloak user access token | HTTP `Authorization: Bearer`에서 추출, JWKS 검증, `iss + sub` binding 해석 | `401` 또는 `AUTHORIZATION_DENIED`; DB에 접속하지 않음 |
|
||||
| 2 | OCI IAM database-access token | server-side client credentials로 얻어 DB connection/context attach에 사용 | DDS context 불가; 사용자 token으로 대체하지 않음 |
|
||||
| 3 | 없음(내부 context) | `DDS_U_n`의 data role/data grant로 target view를 query | Oracle default deny; 결과 반환 안 함 |
|
||||
|
||||
MCP endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp/dds/sse`이며, SSE transport의 message endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp/dds/messages`다. browser 주소창으로 SSE URL을 여는 것은 OAuth 로그인 페이지가 아니라 event stream 연결 시도이므로 유효한 동작 검증 방법이 아니다. AIPF OAuth 등록 또는 Bearer token을 포함한 MCP client로 접속해야 한다.
|
||||
|
||||
### 적용 결과와 검증
|
||||
|
||||
| 구성 요소 | 적용값 | 상태 |
|
||||
|---|---|---|
|
||||
| OCI IAM database resource | `DDS_ORACLE_DB_TEST` / `DB_ACCESS_SCOPE` | 적용 완료 |
|
||||
| OCI IAM service client | `DDS_MCP_SERVICE_TEST`, client credentials만 허용 | 적용 완료 |
|
||||
| Autonomous DB identity provider | `OCI_IAM`, database resource app ID와 OCI IAM domain URL 등록 | 적용 완료 |
|
||||
| DB signing-key credential | `OCI_IAM_DOMAIN_DB_CRED$` | 적용 완료 |
|
||||
| DB application identity | `DDS_MCP_SERVICE_TEST` → OCI IAM service client ID | 적용 완료 |
|
||||
| HMM DDS MCP runtime | client ID·secret·scope를 권한 제한 환경 파일로 로드 | 적용 완료 |
|
||||
| Alice data grant | `DDS_U_1_ROLE` → `CB_VECTOR_SEARCH_DOCUMENTS` SELECT | 적용 완료 |
|
||||
| Bob data grant | 없음 | default deny |
|
||||
|
||||
database-access token은 `resource_app_id`, `tenant_iss`, scope가 DB identity provider 등록과 모두 일치해야 한다. OCI IAM domain URL은 token의 issuer와 같은 정규형(`:443` 포함)을 사용했다. 포트가 빠진 URL로 등록하면 Oracle이 `ORA-52602`(invalid database access token)으로 context 사용을 거부한다.
|
||||
|
||||
2026-08-04 검증 결과는 다음과 같다. 동일한 `dds_vector_search` 호출에서 Alice는 DDS context attach 후 query가 성공했고, Bob은 data grant가 없어 보호 객체 조회가 거부됐다. 초기에는 HMM knowledge chunk 데이터가 없어 Alice의 성공 응답 행 수가 `0`이었으나, 이후 비교 가능한 테스트 문서를 추가하여 Alice의 1건 반환까지 재검증했다.
|
||||
|
||||
이후 권한 차이를 눈으로 확인할 수 있도록 `DDS_AD_ALICE_LEAVE_001` 테스트 문서와 `DDS_AD_TEST` 태그를 HMM knowledge 원천에 추가했다. 같은 `휴가` 검색에서 Alice는 이 문서 1건을 반환하고 Bob은 `ORA-00942` 기반 default deny로 거부되는 것을 확인했다.
|
||||
|
||||
### AIPF 화면에서의 권한 차이 해석
|
||||
|
||||
동일한 AIPF Agent 요청을 Alice와 Bob으로 실행한다.
|
||||
|
||||
```text
|
||||
dds_vector_search 도구를 사용해서 "휴가"를 검색해줘.
|
||||
embeddingMode는 DEMO, limit은 10으로 해줘.
|
||||
```
|
||||
|
||||
| 로그인 사용자 | AIPF에서 보이는 결과 | DB 측 실제 의미 | 판정 |
|
||||
|---|---|---|---|
|
||||
| `dds-alice` | `휴가 정책 - Alice 전용 테스트` 1건 반환 | Keycloak `sub` → `DDS_U_1`, `DDS_U_1_ROLE`의 SELECT DATA GRANT가 `CB_VECTOR_SEARCH_DOCUMENTS`에 적용 | 허용 |
|
||||
| `dds-bob` | “검색에 실패했습니다”, “DDS 보호 객체를 조회할 수 없습니다” | Keycloak `sub` → `DDS_U_2`, 해당 role에 DATA GRANT가 없어 Oracle이 `ORA-00942`로 보호 객체를 숨김 | default deny / 정상 차단 |
|
||||
|
||||
따라서 Bob의 AIPF 화면 문구는 시스템 장애가 아니라 의도한 보안 결과다. 현재 MCP 응답은 SQL 오류를 외부에 노출하지 않기 위해 일반 문구로 변환한다. 운영 UI에서는 이를 “접근 권한이 없습니다”로 표시하도록 개선할 수 있지만, PoC의 권한 검증 자체는 Alice 허용·Bob 거부로 완료됐다.
|
||||
|
||||
## 5. 운영 보안 기준과 장애 구분
|
||||
|
||||
### secret과 token의 보관 원칙
|
||||
|
||||
| 값 | 소유자 | 허용 위치 | 금지 위치 |
|
||||
|---|---|---|---|
|
||||
| AD 사용자 비밀번호 | 사용자/AD | AD에서 hash로 관리, 사용자가 Keycloak login form에만 입력 | MCP 설정, AIPF secret, Git, 문서 |
|
||||
| Keycloak AIPF client secret | AIPF OAuth client | 권한 제한 secret store 또는 `.runtime/aipf-dds-mcp-client.env` | Git, Redmine 본문, 채팅 |
|
||||
| OCI IAM service client secret | MCP backend | HMM host의 mode 600 환경 파일/secret manager | AIPF browser, Java source, Git |
|
||||
| Keycloak user access token | AIPF/MCP 요청 경로 | AIPF의 보호된 token 저장소, HTTPS request header | URL query, application log |
|
||||
| OCI IAM DB service token | MCP process memory | token endpoint 응답 및 DB context attach | browser, client log, source code |
|
||||
|
||||
secret rotation 시에는 Keycloak 또는 OCI IAM에서 새 secret을 만들고, 해당 runtime secret store만 갱신한 뒤 서비스를 재시작한다. 이전 secret의 폐기는 새 token 발급과 MCP query를 확인한 후 수행한다. secret의 실제 값은 로그·shell history·스크린샷에도 남기지 않는다.
|
||||
|
||||
### default deny가 정상인 경우와 장애인 경우
|
||||
|
||||
| 관측 결과 | 가능한 원인 | 기대 처리/조치 |
|
||||
|---|---|---|
|
||||
| 이전 Alice로 자동 로그인 | Keycloak SSO cookie가 남아 있음 | logout 후 Bob으로 다시 인증 |
|
||||
| `401` 또는 tools discovery 실패 | Bearer token 없음/만료, issuer·audience 검증 실패, AIPF source OAuth 설정 누락 | AIPF OAuth 설정과 Keycloak token claim 확인 |
|
||||
| `invalid_request: Missing parameter: code_challenge_method` | Keycloak client에 PKCE 강제인데 AIPF가 PKCE를 보내지 않음 | 현재 AIPF client의 PKCE 정책을 호환 설정으로 조정; AIPF 지원 후 S256 전환 |
|
||||
| `unauthorized_client` / invalid client credentials | AIPF에 잘못된 Keycloak client secret 저장 | 안전한 runtime 파일에서 secret을 다시 복사하고 source 재연결 |
|
||||
| `DDS_CONTEXT_UNAVAILABLE` | OCI IAM client/scope/token endpoint 또는 DB external auth 구성 누락 | service client scope, DB application identity, domain issuer URL 점검 |
|
||||
| Oracle `ORA-52602` | database-access token issuer/resource/scope와 DB 등록값 불일치 | Domain URL 정규형(이 환경은 `:443` 포함), resource app ID와 scope 점검 |
|
||||
| Alice는 결과, Bob은 “DDS 보호 객체” 오류 | Bob에 DATA GRANT 없음 | 의도한 default deny. 정책상 필요한 경우에만 최소 grant 추가 |
|
||||
| Alice도 Bob도 모두 결과 없음 | source data 부재, vector/embedding 조건, 공통 service token 문제 | 데이터 존재 여부와 DDS context attach를 분리해 점검 |
|
||||
|
||||
권한 거부와 인프라 장애를 구분하기 위해 MCP 내부 log에는 SQLState/Oracle error code를 남길 수 있으나, 외부 MCP 응답에는 table명·SQL·DB credential 정보를 노출하지 않는다. 감사 로그에는 요청 시각, correlation ID, token의 `iss + sub` hash 또는 내부 user ID, 선택된 DDS END USER, tool명, allow/deny 결과를 남긴다.
|
||||
|
||||
### 최소 권한 운영 원칙
|
||||
|
||||
1. `DDS_MCP_SERVICE_TEST`에는 database-access scope 외의 OCI 권한을 부여하지 않는다.
|
||||
2. DB application identity는 MCP runtime 전용이며, 개인 사용자나 AIPF browser가 직접 사용하지 않는다.
|
||||
3. local DDS END USER에는 필요한 DATA ROLE만 붙이고, 권한은 object/row/column 단위로 좁힌다. 새 사용자는 grant 없이 생성하는 default deny를 기본으로 한다.
|
||||
4. `CB_EXTERNAL_IDENTITY_BINDING` 변경은 관리자만 수행하고 issuer와 immutable subject를 audit한다. UPN/email 변경만으로 기존 권한이 다른 계정에 이전되지 않아야 한다.
|
||||
5. HTTPS는 Keycloak, AIPF, MCP 모두에서 강제하고, JWKS·token endpoint 호출의 TLS 검증을 끄지 않는다.
|
||||
6. AD DS VM의 RDP와 LDAP 접근은 관리망/허용 IP로 제한한다. public LDAP/LDAPS를 인터넷에 열지 않는다.
|
||||
7. 테스트 종료 후 Windows VM, public DNS, test clients와 secret의 보존·폐기 여부를 Redmine에 기록한다.
|
||||
|
||||
## 6. Entra ID로 전환할 때
|
||||
|
||||
Entra tenant가 확보되면 AD DS/bridge 테스트에서 확인한 `issuer + immutable subject -> CB_APP_USER -> DDS END USER` 계약은 유지한다. 바뀌는 부분은 token issuer와 JWKS 검증 설정뿐이다. Entra의 claim 이름(`oid`, `sub`, `preferred_username` 등)은 실제 발급 토큰을 확인한 뒤 결정하며, `sub` 단독이 아니라 tenant/issuer 경계를 반드시 포함한다.
|
||||
|
||||
## 7. 완료 기준
|
||||
|
||||
- [x] `dds.test` AD forest와 두 테스트 사용자가 생성되었다.
|
||||
- [x] Keycloak LDAP bridge가 HTTPS OIDC JWT를 발급하고 각 사용자가 서로 다른 안정 subject를 가진다.
|
||||
- [x] MCP가 JWT signature/issuer/audience를 검증하고 `issuer + sub` 매핑을 해석한다.
|
||||
- [x] subject 매핑을 통해 각 요청에 대응하는 DDS END USER context만 attach된다.
|
||||
- [x] 서로 다른 권한의 동일 MCP 호출에서 데이터 행/열 결과가 달라진다.
|
||||
- [ ] 미매핑·만료·issuer/audience 불일치 요청은 데이터 접근 전에 거부된다.
|
||||
- [ ] 종료 시 테스트 VM과 전용 네트워크 리소스의 정리 여부 및 비용 상태를 Redmine에 기록한다.
|
||||
@@ -1,151 +0,0 @@
|
||||
# Cookbook: OCI AD Bridge로 DDS MCP 사용자 인증 전환
|
||||
|
||||
[개요로 돌아가기](README.md) · [아키텍처](architecture.md) · [트러블슈팅](troubleshooting.md)
|
||||
|
||||
## 적용 순서
|
||||
|
||||
Keycloak을 삭제하지 않는다. 아래 단계를 순서대로 끝내고 Alice/Bob DDS 결과를 확인한 뒤에만 AIPF의 기본 MCP source를 OCI 경로로 바꾼다.
|
||||
|
||||
| 단계 | 작업 | 성공 판정 | 실패 시 |
|
||||
|---:|---|---|---|
|
||||
| 0 | AD·네트워크·관리 경로 점검 | AD/LDAPS/OCI HTTPS 연결 가능 | [Windows/LDAPS](troubleshooting.md#windows-ad-bridge) |
|
||||
| 1 | OCI AD Bridge 생성·Windows client 설치 | Bridge `Connected` | [Windows/AD Bridge](troubleshooting.md#windows-ad-bridge) |
|
||||
| 2 | AD OU/그룹 동기화 | OCI Domain에 Alice/Bob 표시 | [동기화](troubleshooting.md#동기화와-delegated-authentication) |
|
||||
| 3 | delegated authentication 시험·활성화 | AD 비밀번호 로그인 성공 | [동기화](troubleshooting.md#동기화와-delegated-authentication) |
|
||||
| 4 | AIPF OAuth source 등록 | OCI 로그인 → callback 완료 | [OAuth/AIPF](troubleshooting.md#oci-oauth와-aipf) |
|
||||
| 5 | MCP issuer/audience·DB binding 교체 | OCI JWT 검증·DDS mapping 성공 | [MCP/DDS](troubleshooting.md#mcp와-dds) |
|
||||
| 6 | Alice/Bob 비교 및 Keycloak 정리 판단 | Alice 1건, Bob 거부 | [권한](troubleshooting.md#mcp와-dds) |
|
||||
|
||||
## 0. 사전 점검
|
||||
|
||||
- AD Domain: `dds.test`
|
||||
- OCI Identity Domain: `identityAPAC`
|
||||
- Bridge 설치 위치: 운영은 domain-joined Windows member server 권장. 이 PoC는 격리된 AD VM에서 설치 가능 여부를 검증한다.
|
||||
- 네트워크: Bridge host → OCI Domain HTTPS 443, Bridge host → AD LDAPS 636
|
||||
- AD Bridge service account: 동기화 대상 OU 읽기, `cn=Deleted Objects` 읽기, delegated authentication에 필요한 password/lockout attribute 최소 권한
|
||||
- 동기화 대상은 기본 `CN=Users` container가 아니라 Bridge가 선택 가능한 OU에 둔다. 이 PoC는 `OU=Users,OU=DDS-PoC,DC=dds,DC=test` 및 `OU=Groups,OU=DDS-PoC,DC=dds,DC=test`를 사용한다.
|
||||
- OCI User 생성에 필요한 AD 사용자 속성은 최소 `sAMAccountName`, Given Name, Surname, `mail`이다. `mail`은 OCI가 허용하는 RFC 5322 형식의 주소여야 하며, `.test` 같은 내부 TLD는 거부될 수 있다.
|
||||
|
||||
현재 PoC Windows VM은 WinRM HTTPS(5986) 원격 실행이 확인됐다. OCI Run Command는 여전히 `ACCEPTED`에 머물 수 있으므로 installer 실행 수단으로 사용하지 않고 WinRM을 사용한다.
|
||||
|
||||
## 1. OCI AD Bridge 생성과 설치
|
||||
|
||||
1. OCI Console에서 **Identity & Security → Domains → identityAPAC → Directory integrations → Add → Microsoft Active Directory Bridge**를 연다.
|
||||
2. 표시되는 **Bridge client ID/secret**과 Domain URL을 secret store에 보관한다.
|
||||
3. OCI Console에서 내려받은 Bridge installer를 domain-joined Windows로 안전하게 전달한다. 현재 확보한 검증 대상 artifact는 `ad-id-bridge-23.2.92-2301160723.exe`다.
|
||||
4. installer에서 OCI Domain URL/client credential 및 AD Bridge service account를 입력한다.
|
||||
5. **LDAPS**를 선택하고 연결 시험을 통과한다.
|
||||
6. Directory integrations에서 사용자·그룹 OU를 최소 범위로 선택하고 initial sync를 실행한다.
|
||||
|
||||
Bridge client secret은 AIPF OAuth client secret 및 DB service client secret과 다르다. 각 secret을 서로 대체해 입력하지 않는다.
|
||||
|
||||
### 1.1 Installer artifact 관리
|
||||
|
||||
| 항목 | 이 PoC 값 | 적용 원칙 |
|
||||
|---|---|---|
|
||||
| 파일명 | `ad-id-bridge-23.2.92-2301160723.exe` | OCI Directory integrations 화면에서 내려받은 Bridge별 installer를 사용한다. |
|
||||
| 버전 | `23.2.92` | Console이 제공하는 최신 호환 installer인지 설치 직전에 확인한다. |
|
||||
| SHA-256 | `9388dbec5a76dfd19e6d3d5079e8cdccd00f4227e9aed36483961a8d68cdf19d` | 전달 전·후 checksum이 일치해야 한다. |
|
||||
| 원본 위치 | 로컬 `~/Downloads/` (Git 미포함) | 설치 파일·Bridge client secret·AD 비밀번호를 저장소에 commit하지 않는다. |
|
||||
| PoC 대상 위치 | `C:\DDS\installers\ad-id-bridge-23.2.92-2301160723.exe` | Windows에서 같은 SHA-256을 확인한 뒤 실행한다. |
|
||||
|
||||
입력값의 출처는 다음과 같다.
|
||||
|
||||
| installer 입력값 | 출처 | 성공 판정 |
|
||||
|---|---|---|
|
||||
| Identity Domain URL, Bridge client ID/secret | OCI Console의 해당 **Directory integration** 설치 화면 | Installer의 OCI 연결 시험 성공 |
|
||||
| AD Bridge account | `dds.test` AD에서 최소 권한으로 만든 전용 service account | LDAPS 연결 시험 및 initial sync 성공 |
|
||||
| AD server/SSL | `dds.test` LDAPS TCP 636 | 인증서 신뢰 오류 없이 연결 성공 |
|
||||
|
||||
실패하면 [Windows/AD Bridge 문제 해결](troubleshooting.md#windows-ad-bridge)을 먼저 확인한다. installer와 client secret은 Bridge 생성 화면에서 다시 내려받거나 rotation하며, 채팅·문서·명령 이력에 secret 값을 남기지 않는다.
|
||||
|
||||
### 1.2 설치 실행 방식
|
||||
|
||||
이 버전은 WiX Bootstrapper 기반 installer다. `/quiet`으로 실행하면 **response file이 없다는 이유로 종료**하므로, response file 형식이 검증되기 전에는 silent 설치를 사용하지 않는다.
|
||||
|
||||
1. Windows VM에 RDP로 접속한다.
|
||||
2. `C:\DDS\installers\ad-id-bridge-23.2.92-2301160723.exe`를 **Run as administrator**로 실행한다.
|
||||
3. OCI Console의 Bridge 화면에서 확인한 Domain URL, Bridge client ID/secret을 입력하고 OCI 연결 시험을 성공시킨다.
|
||||
4. AD Bridge 전용 service account와 비밀번호를 입력하고 **Use SSL (LDAPS)**를 유지한 채 AD 연결 시험을 성공시킨다.
|
||||
5. 설치 완료 후 Directory integrations의 Bridge 상태가 `Partially configured` 또는 `Connected`로 바뀌는지 확인한다.
|
||||
|
||||
성공 후에만 [2. delegated authentication 활성화](#2-delegated-authentication-활성화)로 진행한다. silent response file을 확보한 경우에도 secret을 response file에 평문 보관하지 않으며, 사용 직후 삭제·rotation 절차를 적용한다.
|
||||
|
||||
### 1.3 LDAPS 인증서와 동기화 범위 구성
|
||||
|
||||
Bridge installer의 `LDAP server is unavailable`은 TCP 636이 열려 있더라도 AD DS가 유효한 LDAPS 인증서를 제공하지 않을 때 발생할 수 있다. PoC에서는 AD DS FQDN인 `hmm-ad-dss-test.dds.test`를 CN/SAN으로 하는 private server certificate를 Local Machine `My`에 설치하고, Bridge host의 Trusted Root에도 신뢰시켰다. AD DS가 새 인증서를 선택하도록 재부팅한 후 FQDN 기준 TLS handshake를 확인한다.
|
||||
|
||||
```powershell
|
||||
$fqdn = 'hmm-ad-dss-test.dds.test'
|
||||
$cert = New-SelfSignedCertificate -DnsName $fqdn, 'hmm-ad-dss-test' `
|
||||
-CertStoreLocation 'Cert:\LocalMachine\My' -Type SSLServerAuthentication
|
||||
Export-Certificate -Cert $cert -FilePath 'C:\DDS\certs\dds-ad-ldaps-root.cer'
|
||||
Import-Certificate -FilePath 'C:\DDS\certs\dds-ad-ldaps-root.cer' `
|
||||
-CertStoreLocation 'Cert:\LocalMachine\Root'
|
||||
```
|
||||
|
||||
운영에서는 self-signed 인증서 대신 사내 CA가 발급한 인증서를 사용한다. `ad.cloud-handson.com` 같은 OIDC 공개 로그인 주소는 LDAPS server name이 아니다. Bridge의 AD 연결은 내부 AD FQDN과 TCP 636을 사용한다.
|
||||
|
||||
Bridge의 OU 선택 화면은 **OU만** 표시하고 기본 `CN=Users` container는 표시하지 않는다. 테스트 계정이 기본 container에 있으면 다음과 같이 전용 OU와 그룹을 만든 뒤 이동한다.
|
||||
|
||||
```text
|
||||
DC=dds,DC=test
|
||||
└─ OU=DDS-PoC
|
||||
├─ OU=Users ← dds-alice, dds-bob
|
||||
└─ OU=Groups ← DDS-DDS-Users
|
||||
```
|
||||
|
||||
`Edit configuration`에서 Users pane에는 `Users`, Groups pane에는 `Groups`만 선택한다. 상위 `dds.test` 또는 `DDS-PoC`를 Include hierarchy와 함께 선택하면 모든 하위 OU가 자동 선택되므로 선택하지 않는다. Supported operations의 AD 역방향 변경 항목은 모두 해제한다. import frequency를 설정하고, delegated authentication을 사용할 계획이면 **Enable local authentication**을 선택하고 **Enable federated authentication**은 해제한다. `Save` 뒤의 **Save Configuration Changes? → OK**까지 눌러야 상태가 `Configured`가 된다.
|
||||
|
||||
### 1.4 Import와 사용자 속성 검증
|
||||
|
||||
Bridge Action 메뉴의 **Import** 또는 configuration 저장으로 full sync를 실행한다. 성공 판정은 OCI Console의 Last import status에서 `Users imported from Active directory = 2`, `Groups imported from Active directory = 1`, failed 값이 모두 `0`인 것이다.
|
||||
|
||||
| AD 사용자 속성 | PoC 예시 | OCI 매핑 목적 |
|
||||
|---|---|---|
|
||||
| `sAMAccountName` | `dds-alice` | OCI User Name |
|
||||
| Given Name / Surname | `Alice` / `DDS` | OCI 필수 `name` |
|
||||
| `mail` | `dds-alice@cloud-handson.com` | OCI Primary Email |
|
||||
|
||||
속성을 보완한 뒤에도 이전 실패 사용자가 재시도되지 않으면 Users/Groups OU 선택을 모두 해제해 Save/OK하고, 다시 필요한 OU만 선택해 Save/OK한다. 이 절차는 full sync를 강제한다. 성공 여부는 OCI Domain Users/Groups와 [동기화 문제 해결](troubleshooting.md#동기화와-delegated-authentication)을 함께 확인한다.
|
||||
|
||||
## 2. delegated authentication 활성화
|
||||
|
||||
1. OCI Domain에서 동기화된 `dds-alice`, `dds-bob`을 확인한다.
|
||||
2. **Security → Delegated authentication → Test Delegated Authentication**에서 각 AD 계정/AD 비밀번호를 시험한다.
|
||||
3. 두 계정이 성공한 뒤에만 `Activate Delegated Authentication`을 켠다.
|
||||
|
||||
이후 OCI 로그인 화면에 입력한 비밀번호는 Bridge를 거쳐 AD DS에서 검증된다. OCI Domain은 비밀번호 원천이 아니라 OIDC issuer다.
|
||||
|
||||
## 3. AIPF OCI OAuth client
|
||||
|
||||
`AIPF_DDS_AD_TEST`는 AIPF 전용 confidential client다.
|
||||
|
||||
| 설정 | 값 |
|
||||
|---|---|
|
||||
| Grant | `authorization_code`, `refresh_token` |
|
||||
| Redirect URI | `https://aipf.cloud-handson.com/agentFactory/v1/tools/mcp/callback` 및 `https://aipf.cloud-handson.com/v1/tools/mcp/callback` |
|
||||
| Scope | `openid profile email offline_access` |
|
||||
| Authorization URL | discovery의 `authorization_endpoint` |
|
||||
| Token / Refresh URL | discovery의 `token_endpoint` |
|
||||
|
||||
discovery URL은 `https://<identity-domain>/.well-known/openid-configuration`이다. endpoint를 추측해 입력하지 않는다. local test 환경의 client credential은 git-ignore된 `.runtime/aipf-dds-oci-ad-client.env`에만 보관한다.
|
||||
|
||||
## 4. AIPF source와 MCP 전환
|
||||
|
||||
1. AIPF에서 기존 Keycloak source를 삭제하지 않고 새 source를 만든다.
|
||||
2. Server URL은 `https://hmm-backoffice.cloud-handson.com/mcp/dds/sse`를 유지한다.
|
||||
3. Authentication mode를 `OAuth`로 선택하고 OCI client ID/secret과 discovery endpoint를 입력한다.
|
||||
4. OCI login에서 Alice로 로그인한 뒤 받은 access token의 `iss`, `aud`, `sub`를 안전하게 확인한다.
|
||||
5. MCP의 issuer/audience와 `CB_EXTERNAL_IDENTITY_BINDING`을 OCI claim 기준으로 함께 바꾼다.
|
||||
6. Alice/Bob 권한 비교가 끝난 후에만 기존 source를 해제한다.
|
||||
|
||||
## 5. 검증과 rollback
|
||||
|
||||
| 검증 | 성공 |
|
||||
|---|---|
|
||||
| Alice `휴가` 검색 | `휴가 정책 - Alice 전용 테스트` 1건 |
|
||||
| Bob 같은 검색 | 권한 없는 보호 객체가 default deny |
|
||||
| 잘못된/만료/미매핑 OCI JWT | DB query 이전에 거부 |
|
||||
|
||||
실패 시 MCP issuer/audience와 AIPF source를 Keycloak 값으로 되돌린다. DB service client, `DDS_U_1`/`DDS_U_2`, DATA GRANT는 OCI 사용자 OAuth 전환과 별개이므로 rollback 대상이 아니다.
|
||||
@@ -1,68 +0,0 @@
|
||||
# 트러블슈팅: OCI AD Bridge · OAuth · DDS MCP
|
||||
|
||||
[개요로 돌아가기](README.md) · [적용 Cookbook](cookbook.md) · [아키텍처](architecture.md)
|
||||
|
||||
## Windows AD Bridge
|
||||
|
||||
| 증상 | 원인 확인 | 해결 |
|
||||
|---|---|---|
|
||||
| WinRM HTTPS TCP/5986 `Connection timed out` | OCI NSG에 관리 workstation `/32`의 TCP 5986 인바운드가 있는지 먼저 확인한다. 규칙을 추가한 뒤에도 timeout이면 Windows 내부 listener/firewall 문제다. | RDP에서 `winrm enumerate winrm/config/listener`, `Get-NetFirewallRule -DisplayGroup 'Windows Remote Management'`로 확인하고 HTTPS listener와 방화벽 rule을 구성한다. Bridge 자체 오류가 아니다. |
|
||||
| OCI Run Command plugin은 `RUNNING`인데 command가 `ACCEPTED`/`VISIBLE` | 단순 `echo`도 실행되지 않으면 script 문제가 아님 | Windows Oracle Cloud Agent/Run Command service, outbound OCI 연결, plugin log를 RDP에서 점검한다. |
|
||||
| AD Bridge installer가 `/quiet`에서 `No response file specified for silent install`으로 종료 | 이 WiX 기반 버전은 silent 설치에 response file이 필수다. | 임의의 response file을 만들지 말고 RDP의 관리자 설치 UI로 진행한다. 검증된 response file을 확보한 경우에만 secret 보관/삭제 정책을 적용해 자동화한다. |
|
||||
| installer에서 `LDAP server is unavailable` | TCP 636 open만 확인하지 말고 AD DS FQDN으로 TLS handshake를 시험한다. handshake가 즉시 종료되면 AD DS LDAPS certificate 문제다. | AD DS FQDN CN/SAN, Server Authentication EKU, private key를 갖춘 certificate를 Local Machine `My`에 설치하고 issuer를 Bridge host Trusted Root에 신뢰시킨 뒤 AD DS 재시작/VM 재부팅한다. |
|
||||
| LDAPS certificate 오류 | Bridge host가 AD DS server/CA certificate를 신뢰하는지 확인 | CA chain을 Windows trust store에 배포한다. OIDC 공개 hostname이 아니라 실제 AD DS FQDN을 certificate와 LDAP endpoint에 사용한다. |
|
||||
| Bridge `Connected`가 되지 않음 | OCI 443, AD 636, Bridge service account credential/권한 확인 | NSG/proxy/firewall 및 최소 AD 권한을 순서대로 점검한다. |
|
||||
|
||||
## Windows 원격 관리 경로가 막힌 경우
|
||||
|
||||
이 PoC VM에서는 OCI NSG의 TCP 5986 누락을 보완했어도 Windows가 HTTPS WinRM 요청에 응답하지 않았다. 따라서 **NSG rule 추가만으로 WinRM이 활성화되는 것은 아니다.**
|
||||
|
||||
RDP로 `DDS\\opc`로 로그인한 뒤 Administrator PowerShell에서 다음 순서로 점검한다. 운영에서는 management source를 반드시 고정 IP 또는 private subnet으로 제한한다.
|
||||
|
||||
```powershell
|
||||
winrm quickconfig
|
||||
winrm enumerate winrm/config/listener
|
||||
Get-NetFirewallRule -DisplayGroup 'Windows Remote Management' |
|
||||
Select-Object DisplayName, Enabled, Direction, Action
|
||||
```
|
||||
|
||||
HTTPS listener가 없다면 서버 인증서의 thumbprint를 지정해 생성하고, TCP 5986 firewall rule을 enable한 뒤 외부에서 `/wsman` endpoint를 재확인한다. OCI Run Command가 `ACCEPTED`/`VISIBLE`에 머물면 그 기능으로 installer를 실행하지 말고, Windows Oracle Cloud Agent와 outbound OCI 연결을 RDP에서 복구한 후 다시 시도한다.
|
||||
|
||||
## 동기화와 delegated authentication
|
||||
|
||||
| 증상 | 원인 확인 | 해결 |
|
||||
|---|---|---|
|
||||
| Alice/Bob이 OCI Domain에 보이지 않음 | 선택한 사용자 OU/하위 OU와 initial sync 상태 | OU 범위와 filter를 수정하고 sync를 재실행한다. |
|
||||
| OU 선택 화면에 `CN=Users` 또는 테스트 계정이 보이지 않음 | 화면은 AD object가 아니라 **OU만** 나열한다. 기본 Users는 container다. | 전용 Users/Groups OU를 만들고 test user/group을 이동한 뒤 browser를 새로고침한다. OU 이름만 선택하면 되며 그 화면에서 계정 내용은 표시되지 않는다. |
|
||||
| 상위 `dds.test` 선택 시 child OU가 자동 선택됨 | Include hierarchy가 선택된 parent OU에 적용됨 | parent selection을 해제하고 Users pane은 `Users`, Groups pane은 `Groups`만 직접 선택한다. |
|
||||
| `Missing required attribute(s): name` | AD Given Name 또는 Surname이 비어 있어 OCI `name` complex attribute를 만들 수 없음 | AD test user의 Given Name, Surname, Display Name을 채운 뒤 full sync한다. |
|
||||
| `primaryEmailNotSpecified` | AD `mail` 속성이 비어 있음 | 유효한 Primary Email 형식의 `mail` 값을 설정한다. |
|
||||
| `invalidEmailFormat` | `.test` 같은 내부 TLD가 OCI email validator에서 거부됨 | PoC라도 RFC 5322 형식으로 OCI가 허용하는 domain의 email을 사용한다. 실제 notification이 필요 없으면 welcome notification을 끈다. |
|
||||
| 속성을 고쳤는데 `Users synced = 0` | Bridge가 이전 실패 user를 재시도 제외하거나 incremental sync만 수행 | Users/Groups OU 선택을 모두 해제해 Save/OK한 뒤 다시 선택해 Save/OK하여 full sync를 강제하고 Import 결과를 재확인한다. |
|
||||
| OCI 로그인 비밀번호가 실패 | delegated authentication test에서 동일 AD 계정으로 재현 | AD 비밀번호, Bridge AD 연결, service account delegated-auth 권한을 확인한다. |
|
||||
| AD 비밀번호를 OCI local password로 입력하려 함 | delegated authentication 활성화 여부 | 성공 시험 후 활성화한다. 활성화 뒤 실제 비밀번호 원천은 AD다. |
|
||||
| `Security → Delegated authentication` 메뉴가 보이지 않음 | Identity Domain의 delegated authentication capability가 테넌트에서 활성화되지 않았을 수 있다. | OCI Console UI를 찾는 문제로 가정하지 말고 domain capability/서비스 등급을 확인한다. OCI tenancy administrator 또는 Oracle Support에 delegated authentication enablement를 요청한다. |
|
||||
| Identity Source PATCH가 `Delegated Authentication is not yet enabled. This feature is currently in beta phase.`로 400 반환 | 해당 Identity Domain에서 delegated authentication feature가 enable되지 않았다. Bridge 설치·동기화·LDAPS 상태와는 별개인 OCI control-plane 제한이다. | 기능 enablement 전에는 OCI OAuth 로그인에 AD 비밀번호를 사용할 수 없다. Keycloak 경로를 유지하거나 OCI local/federated 인증을 임시로 사용하고, enablement 완료 후 delegated auth test/activate를 재개한다. |
|
||||
|
||||
## OCI OAuth와 AIPF
|
||||
|
||||
| 증상/오류 | 원인 | 해결 |
|
||||
|---|---|---|
|
||||
| `/admin/v1/admin/v1/Apps` 및 401 | `oci identity-domains --endpoint`에 `/admin/v1`까지 넣음 | CLI에는 Domain base URL만 사용한다. raw request target에만 `/admin/v1`을 넣는다. |
|
||||
| `No such option: --header` | `oci raw-request` 옵션명 오류 | `--request-headers '{"Content-Type":"application/json"}'`를 사용한다. |
|
||||
| `Missing required attribute(s): basedOnTemplate` | confidential client app template 누락 | `basedOnTemplate.value=CustomWebAppTemplateId`를 지정한다. |
|
||||
| authorize URL이 302 대신 200 | OCI Domain이 sign-in HTML과 session cookie를 반환 | 정상이다. browser에서 OCI login UI가 표시되는지 확인한다. |
|
||||
| AIPF callback 오류 | redirect URI가 두 AIPF 경로 중 실제 요청 경로와 다름 | `/agentFactory/.../callback`과 `/v1/.../callback` 둘 다 등록한다. |
|
||||
| 이전 사용자로 자동 로그인 | OCI Domain browser SSO session 유지 | OCI logout 후 새 MCP source를 연결한다. |
|
||||
|
||||
## MCP와 DDS
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
|---|---|---|
|
||||
| MCP `401` / tools discovery 실패 | OCI issuer/audience가 runtime에 반영되지 않았거나 token 만료 | discovery/JWT claim을 기준으로 issuer·audience를 설정한다. |
|
||||
| token 검증은 되지만 `AUTHORIZATION_DENIED` | OCI `iss + sub` binding 미등록/비활성 | `CB_EXTERNAL_IDENTITY_BINDING`과 DDS END USER mapping을 함께 확인한다. |
|
||||
| `DDS_CONTEXT_UNAVAILABLE` | DB service client/scope/domain URL 설정 문제 | `DDS_MCP_SERVICE_TEST`, `DDS_ORACLE_DB_TEST`, `DB_ACCESS_SCOPE`, DB application identity를 확인한다. |
|
||||
| `ORA-52602` | database-access token issuer/resource/scope 불일치 | DB의 OCI Domain URL 정규형(`:443` 포함), resource app ID/scope를 맞춘다. |
|
||||
| Alice 성공, Bob “DDS 보호 객체” | Bob DATA GRANT 없음 | 의도된 default deny다. 정책상 필요할 때만 최소 grant를 추가한다. |
|
||||
|
||||
외부 오류 메시지에는 SQL, table명, secret을 노출하지 않는다. 내부 감사에는 correlation ID, 내부 user ID, 선택 DDS END USER, allow/deny 결과를 남긴다.
|
||||
@@ -1,55 +0,0 @@
|
||||
# HMM Select AI annotation 제외 프로파일 비교
|
||||
|
||||
Redmine: #749
|
||||
|
||||
상태: Approved
|
||||
|
||||
구현 대상: `database/adb/85_hmm_hr_select_ai_no_annotation_profile.sql`,
|
||||
`database/adb/86_hmm_select_ai_annotation_benchmark.sql`
|
||||
|
||||
## 목적
|
||||
|
||||
HMM HR Select AI에서 Oracle table/column annotation을 프롬프트에 포함할 때와 제외할 때의 응답
|
||||
시간과 SQL 생성 결과를 같은 조건으로 비교한다. 운영 프로파일과 MCP 설정은 변경하지 않는다.
|
||||
|
||||
## 범위와 결정사항
|
||||
|
||||
- 기준 프로파일은 `HMM_HR_DATA_GPT54_PROFILE`이며 현재 `annotations=true`다.
|
||||
- 비교 프로파일은 `HMM_HR_DATA_GPT54_NOANN_PROFILE`로 만들고 `annotations=false`를 명시한다.
|
||||
- provider, credential, model, region, compartment, object list, temperature, max tokens는 동일하게 유지한다.
|
||||
- 같은 한국어 질문으로 `showprompt` 크기·시간과 `showsql` 생성 시간·성공 여부를 교차 반복 측정한다.
|
||||
- 한국어 질문은 SQLcl에 직접 넣지 않고 UTF-8 Base64를 Oracle 안에서 복원한다.
|
||||
- 측정 결과만으로 운영 프로파일을 자동 전환하지 않는다.
|
||||
|
||||
## 전체 구성
|
||||
|
||||
```text
|
||||
동일 HR 질문
|
||||
├─ HMM_HR_DATA_GPT54_PROFILE ─ annotations=true ─ showprompt/showsql
|
||||
└─ HMM_HR_DATA_GPT54_NOANN_PROFILE ─ annotations=false ─ showprompt/showsql
|
||||
│
|
||||
시간·prompt 크기·SQL 비교
|
||||
```
|
||||
|
||||
두 경로의 유일한 의도적 차이는 `annotations`다. 기존 선사 Federation 프로파일은 이미 annotation을
|
||||
사용하지 않으므로 이번 A/B 대상이 아니다.
|
||||
|
||||
## 성공 기준
|
||||
|
||||
- 두 프로파일의 비교 대상 속성이 `annotations` 외에는 같다.
|
||||
- 각 프로파일이 같은 질문에 유효한 읽기 전용 SQL을 생성한다.
|
||||
- 측정 회차별 시간, 성공 여부, 생성 SQL 길이를 남긴다.
|
||||
- 기존 프로파일과 운영 `BACKOFFICE_SELECT_AI_PROFILE` 값이 바뀌지 않는다.
|
||||
|
||||
## 문서 지도
|
||||
|
||||
- [비교 설계](architecture.md)
|
||||
- [생성·측정·롤백 절차](cookbook.md)
|
||||
- [측정 결과](results.md)
|
||||
- [문제 해결](troubleshooting.md)
|
||||
|
||||
## 현재 상태
|
||||
|
||||
2026-08-11 비교 프로파일 생성과 3회 교차 측정을 완료했다. 현재 HR object list에는 실제
|
||||
annotation이 0건이어서 prompt 크기는 34자만 줄었고, `showsql` 평균은 annotation 사용 2.453초,
|
||||
제외 2.483초로 의미 있는 개선이 없었다. 운영 프로파일은 변경하지 않았다.
|
||||
@@ -1,49 +0,0 @@
|
||||
# 비교 설계
|
||||
|
||||
[개요](README.md) · [실행 절차](cookbook.md) · [측정 결과](results.md) · [문제 해결](troubleshooting.md)
|
||||
|
||||
## 비교 대상
|
||||
|
||||
| 속성 | annotation 사용 | annotation 제외 |
|
||||
|---|---|---|
|
||||
| 프로파일 | `HMM_HR_DATA_GPT54_PROFILE` | `HMM_HR_DATA_GPT54_NOANN_PROFILE` |
|
||||
| provider | `oci` | 동일 |
|
||||
| model | `openai.gpt-5.4-mini` | 동일 |
|
||||
| object list | HMM HR 5개 객체 | 동일 |
|
||||
| temperature | `0.1` | 동일 |
|
||||
| max tokens | `1500` | 동일 |
|
||||
| annotations | `true` | `false` |
|
||||
|
||||
credential 이름과 compartment OCID는 비밀값은 아니지만 환경 종속값이므로 문서에 복사하지 않고
|
||||
DB 원본 프로파일을 기준으로 검증한다. credential 원문이나 토큰은 조회·출력하지 않는다.
|
||||
|
||||
## 측정 방식
|
||||
|
||||
```text
|
||||
run 1: annotation → no annotation
|
||||
run 2: no annotation → annotation
|
||||
run 3: annotation → no annotation
|
||||
```
|
||||
|
||||
호출 순서를 번갈아 배치해 첫 호출 캐시와 일시적 모델 지연이 한쪽에만 몰리는 현상을 줄인다.
|
||||
각 호출은 `DBMS_CLOUD_AI.GENERATE(prompt, profile, action)` 한 번의 경과 시간을
|
||||
`DBMS_UTILITY.GET_TIME`으로 측정한다.
|
||||
|
||||
- `showprompt`: 모델에 전달되는 metadata prompt 크기와 구성 비용 비교
|
||||
- `showsql`: 실제 SQL 생성 시간과 성공 여부 비교
|
||||
|
||||
생성 SQL은 실행하지 않는다. SQL 생성 품질은 `SELECT`/`WITH` 시작 여부, 길이와 주요 객체 사용을
|
||||
검토한다. VPD 결과 행 비교는 운영 프로파일 전환을 결정한 뒤 별도 MCP 회귀 검증에서 수행한다.
|
||||
|
||||
## 안전 경계
|
||||
|
||||
- 기존 프로파일에는 `SET_ATTRIBUTE`, disable, drop을 실행하지 않는다.
|
||||
- 비교 프로파일이 이미 있으면 덮어쓰지 않고 정의를 검증한다.
|
||||
- 벤치마크는 DDL/DML을 생성하거나 실행하지 않고 `showprompt`와 `showsql`만 호출한다.
|
||||
- 새 프로파일은 운영 MCP 설정에 자동 연결하지 않는다.
|
||||
|
||||
Oracle 공식 문서에 따르면 `annotations=true`는 table/column annotation을 LLM metadata에 포함하며,
|
||||
프로파일 속성은 `DBA_CLOUD_AI_PROFILE_ATTRIBUTES`에서 확인할 수 있다.
|
||||
|
||||
- [Oracle DBMS_CLOUD_AI package](https://docs.oracle.com/en/database/oracle/oracle-database/26/arpls/dbms_cloud_ai1.html)
|
||||
- [Oracle DBMS_CLOUD_AI views](https://docs.oracle.com/en-us/iaas/autonomous-database-serverless/doc/dbms-cloud-ai-views.html)
|
||||
@@ -1,65 +0,0 @@
|
||||
# 생성·측정·롤백 절차
|
||||
|
||||
[개요](README.md) · [비교 설계](architecture.md) · [측정 결과](results.md) · [문제 해결](troubleshooting.md)
|
||||
|
||||
## 1. 준비
|
||||
|
||||
- ADMIN으로 HMM ADB에 연결한다.
|
||||
- `HMM_HR_DATA_GPT54_PROFILE`이 `ENABLED`이고 `annotations=true`인지 확인한다.
|
||||
- 운영 `BACKOFFICE_SELECT_AI_PROFILE` 값을 기록하되 credential과 비밀번호는 출력하지 않는다.
|
||||
|
||||
성공 판정: 원본 프로파일과 전체 속성 목록을 읽을 수 있다.
|
||||
|
||||
실패 시: [원본 프로파일이 없거나 소유자가 다름](troubleshooting.md#원본-프로파일이-없거나-소유자가-다름)
|
||||
|
||||
## 2. 비교 프로파일 생성
|
||||
|
||||
```text
|
||||
database/adb/85_hmm_hr_select_ai_no_annotation_profile.sql
|
||||
```
|
||||
|
||||
스크립트는 비교 프로파일이 없을 때만 생성한다. 생성 후 원본과 비교 프로파일의 속성을 조회해
|
||||
`annotations` 외의 차이가 없는지 확인한다.
|
||||
|
||||
성공 판정: `HMM_HR_DATA_GPT54_NOANN_PROFILE`이 `ENABLED`, `annotations=false`다.
|
||||
|
||||
실패 시: [비교 프로파일이 이미 있지만 속성이 다름](troubleshooting.md#비교-프로파일이-이미-있지만-속성이-다름)
|
||||
|
||||
## 3. A/B 측정
|
||||
|
||||
```text
|
||||
database/adb/86_hmm_select_ai_annotation_benchmark.sql
|
||||
```
|
||||
|
||||
동일 질문을 UTF-8 Base64에서 복원하고, 두 프로파일의 `showprompt`와 `showsql`을 번갈아 3회씩
|
||||
호출한다. `BENCHMARK|...` 행을 결과 파일에 저장하고 평균·중앙값을 계산한다.
|
||||
|
||||
성공 판정:
|
||||
|
||||
- 두 프로파일 모두 3회 결과가 있다.
|
||||
- `showsql`이 읽기 전용 SQL을 반환한다.
|
||||
- 시간과 prompt/SQL 길이를 비교할 수 있다.
|
||||
|
||||
실패 시: [호출이 간헐적으로 실패하거나 시간이 크게 흔들림](troubleshooting.md#호출이-간헐적으로-실패하거나-시간이-크게-흔들림)
|
||||
|
||||
## 4. 운영 전환 판단
|
||||
|
||||
이번 실험에서는 운영 값을 바꾸지 않는다. 속도 개선이 반복 확인되고 SQL 품질이 유지될 때만
|
||||
별도 변경으로 `BACKOFFICE_SELECT_AI_PROFILE` 전환과 MCP VPD 회귀 검증을 수행한다.
|
||||
|
||||
2026-08-11 측정에서는 의미 있는 속도 개선이 확인되지 않았으므로 전환하지 않았다. 자세한 수치는
|
||||
[측정 결과](results.md)를 본다.
|
||||
|
||||
## 5. 롤백
|
||||
|
||||
비교 프로파일이 문제를 일으키면 먼저 disable한다.
|
||||
|
||||
```sql
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI.DISABLE_PROFILE('HMM_HR_DATA_GPT54_NOANN_PROFILE');
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
운영 프로파일은 실험 중 변경하지 않으므로 서비스 롤백은 필요 없다. 프로파일 삭제는 측정 이력과
|
||||
의존성을 확인한 뒤 별도 승인하에 수행한다.
|
||||
@@ -1,49 +0,0 @@
|
||||
# 측정 결과
|
||||
|
||||
[개요](README.md) · [비교 설계](architecture.md) · [실행 절차](cookbook.md) · [문제 해결](troubleshooting.md)
|
||||
|
||||
## 결론
|
||||
|
||||
현재 HMM HR 데이터에서는 annotation 제외에 따른 일관된 속도 개선이 확인되지 않았다.
|
||||
annotation 사용 프로파일의 object list에 실제 table/column annotation이 0건이기 때문이다.
|
||||
운영 프로파일은 기존 값을 유지한다.
|
||||
|
||||
## 측정 조건
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 측정일 | 2026-08-11 |
|
||||
| DB 연결 | ADMIN / `hmmaipoc_low` |
|
||||
| 모델 | `openai.gpt-5.4-mini` |
|
||||
| 질문 | 이번 달 팀원별 휴가 사용 현황 조회 |
|
||||
| 반복 | action별·profile별 3회, 호출 순서 교차 |
|
||||
| annotation 사용 | `HMM_HR_DATA_GPT54_PROFILE` |
|
||||
| annotation 제외 | `HMM_HR_DATA_GPT54_NOANN_PROFILE` |
|
||||
|
||||
한국어 질문은 UTF-8 Base64로 전달해 Oracle에서 복원했다. `showsql` 결과는 실행하지 않았다.
|
||||
|
||||
## 결과 요약
|
||||
|
||||
| action | 프로파일 | 평균 | 중앙값 | 최소 | 최대 | 결과 크기 |
|
||||
|---|---|---:|---:|---:|---:|---:|
|
||||
| `showprompt` | annotation 사용 | 693.3ms | 130ms | 120ms | 1,830ms | 3,188자 |
|
||||
| `showprompt` | annotation 제외 | 196.7ms | 130ms | 120ms | 340ms | 3,154자 |
|
||||
| `showsql` | annotation 사용 | 2,453.3ms | 2,430ms | 2,410ms | 2,520ms | 522~547자 |
|
||||
| `showsql` | annotation 제외 | 2,483.3ms | 2,370ms | 2,350ms | 2,730ms | 522~533자 |
|
||||
|
||||
`showprompt` 첫 회차의 1,830ms는 이후 120~130ms로 줄어 초기 metadata cache 영향으로 판단한다.
|
||||
중앙값은 두 프로파일 모두 130ms다. `showsql`은 annotation 제외 프로파일이 평균 기준 30ms 느리고,
|
||||
중앙값 기준 60ms 빠르므로 측정 편차 범위다.
|
||||
|
||||
## 품질 확인
|
||||
|
||||
- `showsql` 6회 모두 `SELECT` 또는 `WITH`로 시작하는 유효한 읽기 전용 SQL을 반환했다.
|
||||
- 회차별 SQL hash가 달라 생성 SQL은 완전히 결정적이지 않았다.
|
||||
- 두 프로파일의 prompt 크기 차이는 34자, 약 1.1%다.
|
||||
- HR object list 5개 테이블의 `ALL_ANNOTATIONS_USAGE` 집계는 0건, 0자다.
|
||||
|
||||
## 판단
|
||||
|
||||
현재 조건에서는 `annotations=false` 프로파일로 운영 전환할 근거가 없다. annotation metadata가 실제로
|
||||
많은 다른 object list에서는 결과가 달라질 수 있으므로, 그 경우 같은 스크립트로 다시 측정해야 한다.
|
||||
비교 프로파일은 후속 시험용으로 유지하되 MCP 운영 설정에는 연결하지 않는다.
|
||||
@@ -1,46 +0,0 @@
|
||||
# 문제 해결
|
||||
|
||||
[개요](README.md) · [비교 설계](architecture.md) · [실행 절차](cookbook.md) · [측정 결과](results.md)
|
||||
|
||||
## 원본 프로파일이 없거나 소유자가 다름
|
||||
|
||||
**증상**: `HMM_HR_DATA_GPT54_PROFILE` 조회 결과가 없거나 `CREATE_PROFILE` 권한 오류가 발생한다.
|
||||
|
||||
**원인**: 다른 DB 또는 다른 사용자로 접속했거나 프로파일 소유자가 ADMIN이 아니다.
|
||||
|
||||
**확인**: `DBA_CLOUD_AI_PROFILES`에서 owner와 profile name을 확인한다.
|
||||
|
||||
**해결**: HMM ADB 지갑·service alias와 ADMIN 연결을 다시 확인한다. 이름이 비슷한 프로파일을 임의로
|
||||
대체하지 않는다.
|
||||
|
||||
## 비교 프로파일이 이미 있지만 속성이 다름
|
||||
|
||||
**증상**: 비교 프로파일은 존재하지만 모델, object list, temperature 또는 annotations 값이 설계와 다르다.
|
||||
|
||||
**원인**: 이전 실험의 프로파일이 같은 이름으로 남아 있다.
|
||||
|
||||
**확인**: `DBA_CLOUD_AI_PROFILE_ATTRIBUTES`에서 두 프로파일을 나란히 비교한다.
|
||||
|
||||
**해결**: 기존 비교 프로파일을 덮어쓰지 않는다. 먼저 disable하고 이력·의존성을 확인한 뒤 새 버전
|
||||
이름을 사용하거나 별도 승인으로 정리한다.
|
||||
|
||||
## 호출이 간헐적으로 실패하거나 시간이 크게 흔들림
|
||||
|
||||
**증상**: 같은 프로파일에서도 회차별 `showsql` 시간이 크게 다르거나 LLM 오류가 발생한다.
|
||||
|
||||
**원인**: 모델 endpoint 부하, 첫 호출 metadata cache, 생성형 SQL의 비결정성일 수 있다.
|
||||
|
||||
**확인**: 두 프로파일을 번갈아 호출했는지, 각 3회 이상 결과가 있는지, 오류 코드가 프로파일 설정
|
||||
오류인지 외부 모델 오류인지 구분한다.
|
||||
|
||||
**해결**: 실패 회차를 숨기지 않고 기록한다. 같은 조건으로 추가 회차를 실행하고 평균뿐 아니라
|
||||
중앙값·최솟값·최댓값을 함께 비교한다.
|
||||
|
||||
## 한국어 질문이 깨져 SQL 품질이 달라짐
|
||||
|
||||
**증상**: 질문의 한글이 깨지거나 관련 없는 SQL이 생성된다.
|
||||
|
||||
**원인**: SQLcl을 통해 한국어 literal을 직접 전달했다.
|
||||
|
||||
**해결**: UTF-8 질문을 Base64 ASCII로 전달하고 Oracle에서 `UTL_I18N.RAW_TO_CHAR`로 복원한다.
|
||||
직접 SQL string 또는 `q'[...]'`를 사용하지 않는다.
|
||||
@@ -1,48 +0,0 @@
|
||||
# HMM HTML 리포트 MCP
|
||||
|
||||
## 목적
|
||||
|
||||
선사 실적 Federation 조회의 구조화 결과를 HMM 기업 리포트 HTML로 표현한다. 리포트 도구는
|
||||
DB를 다시 조회하거나 자연어를 해석하지 않는다.
|
||||
|
||||
## 결정사항
|
||||
|
||||
- 기준 MCP endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp` 하나이며, 별도 호환성 시험 endpoint로 교체하지 않는다.
|
||||
- 기존 조회 route `search_carrier_performance`는 DB Agent Tool `HMM_CARRIER_FEDERATION_SEARCH`를 그대로 호출한다. 이 경로가 자연어를 Select AI Federation으로 처리한다.
|
||||
- MCP 도구 `render_hmm_carrier_report`는 `reportJson` 문자열 하나를 입력으로 받는다.
|
||||
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
|
||||
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
|
||||
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
|
||||
- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다.
|
||||
- 같은 대화에서 데모 사용자를 전환해도 현재 선택 사용자 ID가 질문의 `내`와 제목 범위를 결정한다. 다른 사용자의 이전 대화는 현재 사용자의 문맥으로 사용하지 않는다.
|
||||
- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다.
|
||||
- 포털은 사용자 질문을 그대로 두 Tool에 재사용하지 않는다. 첫 조회에는 데이터 조건만 남긴 질문을 전달하고, `HTML로 보여줘` 같은 표현 요청은 renderer 선택과 제목 생성에만 사용한다.
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
```text
|
||||
HMM 포털 → `https://hmm-backoffice.cloud-handson.com/mcp` → 데이터 조회 도구
|
||||
→ 포털의 범용 결과 정규화 → 이전 결과 입력형 렌더링 도구 → HTML 미리보기
|
||||
```
|
||||
|
||||
리포트에 보이는 값은 조회 결과 행에서 계산되므로, 자연어 답변과 별개로 재해석되지 않는다.
|
||||
|
||||
## 문서 지도
|
||||
|
||||
- [아키텍처와 입력 계약](architecture.md)
|
||||
- [적용·검증 절차](cookbook.md)
|
||||
- [문제 해결](troubleshooting.md)
|
||||
|
||||
## 현재 상태
|
||||
|
||||
Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리포트·차트·HTML 요청에서
|
||||
도구 이름을 고정하지 않고, 발견한 도구의 설명·입력 스키마를 기준으로 데이터 조회 뒤 렌더링을
|
||||
순차 호출하도록 운영 배포한다.
|
||||
|
||||
2026-08-10 운영 검증에서 같은 endpoint와 Bearer 문맥으로 기존 Select AI 조회 8건, renderer 입력
|
||||
8건, HTML 18,670자를 연속 호출해 확인했다. 조회 응답은 `response.result` 안의 JSON 배열 문자열로
|
||||
반환되므로 포털이 이를 범용적으로 구조화한다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며,
|
||||
호출 시 전달된 `report`와 `rows`만 표시한다.
|
||||
|
||||
2026-08-10 사용자 전환 회귀 검증에서는 E1001 제목이 팀장·팀원 범위를 유지하고, 같은 대화에서
|
||||
E1002로 전환한 `내 담당 선사` 제목은 E1002 범위로 생성되며 E1001 식별자가 섞이지 않음을 확인했다.
|
||||
@@ -1,131 +0,0 @@
|
||||
# 아키텍처와 입력 계약
|
||||
|
||||
## 책임 경계
|
||||
|
||||
| 구성요소 | 책임 | DB 접근 |
|
||||
|---|---|---|
|
||||
| `search_carrier_performance` | 기존 `HMM_CARRIER_FEDERATION_SEARCH`를 통한 Bearer/VPD 기반 Select AI Federation 조회 | 허용 |
|
||||
| AI 웹 콘솔 | 도구 계약을 판별해 조회 결과를 정규화하고 렌더러를 후속 호출 | 없음 |
|
||||
| `render_hmm_carrier_report` | DB CLOB 템플릿에 허용된 JSON을 매핑 | 템플릿만 읽음 |
|
||||
| 브라우저 | 반환 HTML 표시·다운로드 | 없음 |
|
||||
|
||||
```text
|
||||
HMM portal ──► /mcp (Bearer) ──► Backoffice MCP facade ──► DB Agent Tool
|
||||
│
|
||||
VPD applied rows
|
||||
│
|
||||
Console
|
||||
│
|
||||
report MCP tool call
|
||||
│
|
||||
HMM_REPORT_TEMPLATES
|
||||
│
|
||||
HTML 응답
|
||||
```
|
||||
|
||||
리포트 MCP도 같은 Bearer 인증을 통과해야 하지만, 권한 판단은 조회 MCP에서 끝난다. 리포트
|
||||
입력의 임의 사용자 ID를 신뢰하거나 DB를 재조회하지 않는다.
|
||||
|
||||
## 포털의 범용 순차 호출 규칙
|
||||
|
||||
포털은 `render_hmm_carrier_report`라는 이름을 조건문에 넣지 않는다. 발견한 MCP 도구가 아래
|
||||
계약을 동시에 보이면 **이전 결과 입력형 렌더러**로 분류한다.
|
||||
|
||||
- 입력 스키마에 `reportJson`·`report_payload`처럼 리포트 JSON/payload를 받는 문자열이 있다.
|
||||
- 설명에 HTML/리포트/렌더링 의도와 `조회 결과`, `후속 처리`, `already-authorized`처럼 선행 결과를
|
||||
사용한다는 의도가 있다.
|
||||
|
||||
사용자가 리포트·보고서·대시보드·차트·HTML을 요청하고 이 렌더러가 발견되면, 기존 LLM 라우터는
|
||||
렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items`, `results`, `rows` 또는
|
||||
`response.result` 안의 JSON 배열 문자열을 같은 방식으로 구조화하고, camelCase JSON으로 정규화해
|
||||
두 번째 도구에 전달한다. 포털은 렌더러의 `html` 응답을 iframe으로 표시한다. 이 규칙은 특정
|
||||
Tool 이름, 사용자 코드나 예상 행 수를 조건으로 사용하지 않는다.
|
||||
|
||||
## 변경 금지 경계
|
||||
|
||||
- `search_carrier_performance`의 DB target은 기존 `HMM_CARRIER_FEDERATION_SEARCH`다.
|
||||
- 별도 호환성 시험 서버 `hmm-mcp.cloud-handson.com`은 이 운영 경로를 대신하지 않는다.
|
||||
- HTML 추가를 이유로 조회 Agent Tool, Select AI profile, VPD 정책 또는 조회 package를 생성·교체하지 않는다.
|
||||
- renderer는 선행 결과만 표현하며 누락 행을 조회하거나 고정 샘플로 채우지 않는다.
|
||||
|
||||
## `reportJson` 계약
|
||||
|
||||
최상위에는 `report` 객체와 `rows` 배열만 허용한다. `report`에는 `id`, `category`, `title`,
|
||||
`generatedAt`, `requestedBy`, `question`, `answer`, `execution`, `evidence`, `limitation`을
|
||||
넣는다. 행에는 담당자·선사 식별자와 최신 KPI만 넣는다.
|
||||
|
||||
`report.title`은 사용자 질문 원문이 아니다. 포털이 모델에 다음 제목 계약을 지시해 만든 짧은
|
||||
업무 제목이다.
|
||||
|
||||
- `HTML로 보여줘`, `리포트로 만들어줘`와 같은 출력 형식·행동 문구는 제거한다.
|
||||
- 사용자·팀·업무 대상처럼 범위를 구분하는 식별자는 유지한다.
|
||||
- 문장형 답변이 아니라 화면 머리글에 맞는 명사형 제목으로 만든다.
|
||||
- 템플릿이 붙이는 고정 부제와 같은 문구를 반복하지 않는다.
|
||||
|
||||
제목 생성이 실패하면 전체 질문을 제목으로 사용하지 않고 짧은 일반 업무 제목으로 안전하게
|
||||
대체한다. 이 규칙은 특정 사용자 코드나 조회 행 수를 조건으로 사용하지 않는다.
|
||||
|
||||
### 현재 선택 사용자와 대화 문맥
|
||||
|
||||
포털의 데모 사용자 선택값은 Bearer token 선택뿐 아니라 질문 해석과 제목 범위의 기준이다.
|
||||
`내`, `나`, `우리` 같은 1인칭 표현은 현재 선택 사용자 ID를 기준으로 독립 질문으로 바꾼다.
|
||||
대화 이력은 같은 `selected_user_id`로 저장된 turn만 불러오며, 제목 생성 모델에도 현재 선택 사용자
|
||||
ID를 별도 입력으로 전달한다. 이전 사용자의 질문에 명시된 팀장·팀 범위가 현재 사용자의 제목으로
|
||||
전파되어서는 안 된다.
|
||||
|
||||
```text
|
||||
현재 사용자 E1001 + "E1001 팀장의 팀원별 ..." → E1001 팀 범위 제목 → VPD 결과 8건
|
||||
현재 사용자 E1002 + "내 담당 선사 ..." → E1002 개인 범위 제목 → VPD 결과 2건
|
||||
```
|
||||
|
||||
제목 문자열이나 예상 행 수를 사용자별로 하드코딩하지 않는다. 사용자 ID, 독립 질문, 실제 선행 조회
|
||||
결과를 모델 입력으로 제공하고 공통 제목 지침으로 생성한다. `report.requestedBy`는 질문에서 추측하지
|
||||
않고 현재 선택 사용자 ID를 사용한다.
|
||||
|
||||
## 포털 표시 계약
|
||||
|
||||
renderer 응답에 유효한 `html` 또는 `rendered_html`이 있으면 HTML artifact가 최종 표현물이다.
|
||||
포털의 일반 답변 생성 지침은 HTML 태그, Markdown 표, 업무 행 전체를 다시 만들지 않고 제목과
|
||||
조회 건수, 아래 리포트 확인 안내만 반환한다. 포털은 같은 조건을 출력 후에도 검사해 모델이
|
||||
HTML을 반환하더라도 안전한 짧은 안내문으로 정규화한다.
|
||||
|
||||
## 조회 질문과 표현 요청 분리
|
||||
|
||||
사용자의 한 문장에는 데이터 요구와 표현 요구가 함께 있을 수 있다. 포털은 Agent instruction으로
|
||||
두 의도를 분리한다.
|
||||
|
||||
```text
|
||||
원문: E1001 팀 선사 KPI를 HTML로 보여줘
|
||||
조회 단계: E1001 팀 선사 KPI를 보여줘
|
||||
표현 단계: 조회된 구조화 행을 HTML 리포트로 렌더링
|
||||
```
|
||||
|
||||
조회 단계 재작성은 직원·팀·기간·지표·필터를 모두 유지하고 `HTML`, `리포트`, `차트`, `대시보드`
|
||||
같은 출력 형식과 생성 행동만 제거한다. 원문은 `report.question`에 보존한다. Select AI가 만든
|
||||
`htmlRow`, HTML 태그 또는 Markdown 표는 구조화 업무 행으로 간주하지 않으며 renderer 입력으로
|
||||
전달하지 않는다.
|
||||
|
||||
서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
|
||||
`</script>`를 이스케이프한다. payload는 저장하지 않는다.
|
||||
|
||||
## 배포 도구 계약
|
||||
|
||||
`BACKOFFICE_MCP_TOOLS`에 다음과 같이 등록한다. 실제 환경 변수에는 비밀값을 넣지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "render_hmm_carrier_report",
|
||||
"label": "HMM 선사 실적 HTML 리포트",
|
||||
"description": "VPD 적용 선사 실적 조회 결과를 HMM HTML 리포트로 표현합니다.",
|
||||
"argumentName": "reportJson",
|
||||
"argumentDescription": "정규화된 선사 실적 리포트 JSON입니다.",
|
||||
"executionType": "AGENT_TOOL",
|
||||
"targetName": "HMM_CARRIER_REPORT_RENDERER",
|
||||
"targetParameterName": "P_REPORT_JSON"
|
||||
}
|
||||
```
|
||||
|
||||
`targetName`은 DBMS_CLOUD_AI_AGENT custom tool `HMM_CARRIER_REPORT_RENDERER`다.
|
||||
|
||||
템플릿에는 `const reportData = __REPORT_DATA__;` 자리만 둔다. DB 함수는 호출 시 전달받은 JSON을
|
||||
이 자리에 삽입하고, 조회 Tool을 호출하거나 기본 업무 행을 보충하지 않는다.
|
||||
@@ -1,56 +0,0 @@
|
||||
# 적용·검증 절차
|
||||
|
||||
## 1. 준비
|
||||
|
||||
- `vpd-backoffice` 배포본에 HTML 템플릿과 HMM CI 리소스가 포함되어야 한다.
|
||||
- 조회 도구 `search_carrier_performance`가 기존 `HMM_CARRIER_FEDERATION_SEARCH`를 가리켜야 한다.
|
||||
- 배포 환경의 `BACKOFFICE_MCP_TOOLS`에 `AGENT_TOOL` 도구 계약을 추가한다.
|
||||
|
||||
먼저 `database/adb/83_hmm_carrier_html_report_tool.sql`을 실행한 뒤 다음 명령으로 현재
|
||||
승인 템플릿을 CLOB에 적재한다. 스크립트는 템플릿을 UTF-8 Base64로 복원한다.
|
||||
|
||||
```bash
|
||||
./scripts/load-hmm-carrier-report-template.sh
|
||||
```
|
||||
|
||||
## 2. 확인
|
||||
|
||||
1. 동일 Bearer token으로 `tools/list`를 호출한다.
|
||||
2. `render_hmm_carrier_report`와 입력 속성 `reportJson`이 보이는지 확인한다.
|
||||
3. 먼저 `search_carrier_performance`를 호출한다. 현재 Agent Tool 응답의 실제 행은 `response.result` 안의 JSON 배열 문자열이다.
|
||||
4. 포털이 해당 배열을 행으로 구조화했는지 확인하고 질문·답변 근거·행을 `reportJson`으로 구성해 리포트 도구를 호출한다.
|
||||
5. 응답 `response.html`을 새 탭 또는 sandboxed iframe에서 연다.
|
||||
|
||||
성공 판정은 조회 원본 행 수와 `reportJson.rows` 수가 같고, 제목, 담당자별 막대 차트, 위험 분포,
|
||||
상세 표가 `rows`와 일치하는 것이다.
|
||||
템플릿 내부에 `__REPORT_DATA__`가 남지 않고, 검증 payload의 담당자·선사 식별자가 반환 HTML에
|
||||
포함되는지도 확인한다.
|
||||
|
||||
## 3. 포털 순차 실행 확인
|
||||
|
||||
1. 포털 MCP 설정의 endpoint를 `https://hmm-backoffice.cloud-handson.com/mcp`로 설정하고,
|
||||
허용 목록에 조회 도구와 리포트 렌더링 도구를 모두 넣는다.
|
||||
2. 포털에서 예를 들어 `E1001 팀의 선사 최신 실적을 HMM 리포트로 만들어줘`라고 요청한다.
|
||||
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
|
||||
첫 단계 MCP argument에는 `HTML로 보여줘`, `리포트로 만들어줘` 같은 표현 요청이 없어야 한다.
|
||||
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
|
||||
확인한다.
|
||||
5. 일반 답변에는 `<table>`, `<div>` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만
|
||||
표시되는지 확인한다.
|
||||
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
|
||||
7. `reportJson.rows`의 각 행이 `employeeCode`, `carrierCode`, KPI처럼 업무 필드를 가지며,
|
||||
`htmlRow`나 `<tr>` 문자열을 포함하지 않는지 확인한다.
|
||||
8. 같은 대화에서 사용자를 E1001에서 E1002로 전환한 뒤 `내 담당 선사 ... 리포트로 보여줘`를
|
||||
요청한다. E1002의 독립 질문·제목·`report.requestedBy`에 E1001이 없어야 하며, E1002 권한 범위의
|
||||
행만 표시되어야 한다.
|
||||
|
||||
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
|
||||
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
|
||||
아니며, [문제 해결](troubleshooting.md)의 중첩 응답 항목을 확인한다.
|
||||
|
||||
실패 시 [문제 해결](troubleshooting.md)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다.
|
||||
|
||||
## 4. 롤백
|
||||
|
||||
`BACKOFFICE_MCP_TOOLS`에서 리포트 도구 항목을 제거하고 서비스를 재기동하면 기존 조회 MCP에는
|
||||
영향 없이 리포트 후속 호출만 중단된다. 템플릿은 DB나 사용자 데이터에 변경을 만들지 않는다.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user