diff --git a/.env.example b/.env.example index ca57789..a97b948 100644 --- a/.env.example +++ b/.env.example @@ -43,7 +43,8 @@ export BACKOFFICE_SESSION_COOKIE_SECURE="false" export BACKOFFICE_REMEMBER_ME_ENABLED="false" export BACKOFFICE_REMEMBER_ME_KEY="" export BACKOFFICE_REMEMBER_ME_DAYS="14" -export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords" +# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다. +export BACKOFFICE_ORDS_BASE_URL="" export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10" # ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로 # ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다. @@ -51,7 +52,41 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_PASSWORD="" -# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- +# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다. +export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" +export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}" +export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}" +export BACKOFFICE_SELECT_AI_PROFILE="" +# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다. +# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요. +export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}" +export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS" +export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD="" +# Optional deployment-specific JSON contract. Keep project rules out of Java. +export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE="" + +# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 --- +# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다. +export BACKOFFICE_CATALOG_OWNER="APP_OWNER" +export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]' +export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice" +export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice" +export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터" + +# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다. +export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp" +export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice" +export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql" +export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL" +export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다." +export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다." +export BACKOFFICE_MCP_TOOLS='' + +# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다. +export BACKOFFICE_MASKING_POLICIES='' +export BACKOFFICE_SECURITY_SQL_SCRIPTS='' + +# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- export BACKOFFICE_AI_ENABLED="false" export BACKOFFICE_AI_PROVIDER="openai" # openai | oci export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com @@ -70,7 +105,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID="" export VPDTEST1_API_KEY="" export VPDTEST2_API_KEY="" -# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) --- +# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) --- # ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합. # 4명의 데모 유저: # vpduser_my → MySQL view 만 SELECT 가능 @@ -83,7 +118,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26" export VPDUSER_NONE_PASSWORD="RowFilter#None26" # --- (3b) OPTIONAL — DDS 변형 데모용 엔드유저 비밀번호 --- -# sql/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용. +# database/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용. # 동일한 4-user 매트릭스를 declarative Data Grants 로 재현. VPD 데모와 공존. export DDSUSER_MY_PASSWORD="DdsGrant#My2026" export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026" @@ -104,7 +139,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, ...) --- -# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성. +# database/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" diff --git a/.gitignore b/.gitignore index 6631670..357e835 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ logs/ # Java / Maven target/ +# Python +__pycache__/ +*.py[cod] +.venv/ +data/ + # Locally downloaded development tools (for example SQLcl) .tools/ diff --git a/README.md b/README.md index 62ea566..955555a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ End-to-End 데모입니다. 행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만 `email` 컬럼은 항상 `NULL` 로 반환. -* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`). +* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`). * DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수. ### 3. 두 가지 구현 — VPD 와 DDS @@ -59,19 +59,21 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드, ## 소스 위치와 Git 저장 기준 -현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다. -두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다. +현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은 +최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다. | 구분 | 저장 위치 | 역할 | |---|---|---| -| 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 스크립트 | +| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 | +| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 | +| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 | +| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 | +| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 | | DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 | -| 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 | +| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 | +| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 | +| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 | +| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON | Git 기준은 다음과 같다. @@ -80,14 +82,12 @@ Git 기준은 다음과 같다. | 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` | | 운영 기준 remote | `gitea` | | 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` | -| 기준 branch | `main` | -| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` | +| HMM 기준 branch | `hmm-backoffice` | 주의: -- 현재 운영·현행화 기준은 `gitea/main`이다. -- `origin/main`은 보조 remote이며, 이 작업공간의 `main`이 `origin/main`보다 앞서 있을 수 있다. -- `poc4_active_source_20260714/`는 배포 VM의 PoC4 활성 소스를 가져온 snapshot이다. 원본은 배포 VM의 `/home/opc/poc_4/` 계열 경로에서 운영되었고, 저장소에는 비밀정보 없이 소스와 설정 sample만 보관한다. +- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다. +- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다. - 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다. --- @@ -97,6 +97,7 @@ 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` | 로그인 시 권한을 세션 컨텍스트로 로딩 | @@ -109,7 +110,7 @@ Git 기준은 다음과 같다. ## 빠른 시작 (One-Click) ```bash -git clone https://github.com//vpd-permission-poc.git +git clone --branch hmm-backoffice https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git cd vpd-permission-poc # 1) 환경값 채우기 @@ -167,7 +168,7 @@ $EDITOR .env ## 데모 시나리오 — 2×2 source access matrix -`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스): +`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스): | DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 | |---|---|---|---|---| @@ -185,7 +186,7 @@ $EDITOR .env (`1=0` predicate) — **deny by default**. * 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음. -`sql/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트 +`database/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트 스푸핑, DBMS_RLS 변경, 매핑 테이블 SELECT) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을 보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다. @@ -197,7 +198,7 @@ $EDITOR .env ## DDS 설정 핵심 -DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이 +DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이 순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다. ### 사전 조건 @@ -257,10 +258,23 @@ 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 검증 헬퍼 -├── sql/ +├── database/ │ ├── 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 diff --git a/ai-web-agent-console/.env.example b/ai-web-agent-console/.env.example new file mode 100644 index 0000000..e3d01e8 --- /dev/null +++ b/ai-web-agent-console/.env.example @@ -0,0 +1,48 @@ +# AI Web Agent Console local/VM configuration example. +# Copy to .env and keep real credentials out of Git. + +# MCP +HMM_MCP_BEARER_TOKEN= +AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45 + +# OCI Generative AI SDK +OCI_AUTH_TYPE=config_file +OCI_CONFIG_FILE=~/.oci/config +OCI_PROFILE=DEFAULT +OCI_GENAI_COMPARTMENT_ID= +AI_WEB_AGENT_CONSOLE_MODEL_PROFILE=gpt54_mini_oci +AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE=gpt54_mini_oci + +# Optional model route overrides +# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini +# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION=us-chicago-1 +# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com + +# Login gateway. Generate PBKDF2 hash and random cookie secret outside Git. +AI_WEB_AGENT_CONSOLE_LOGIN_USER=demo-admin +AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2= +AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET= +PORTAL_AUTH_BIND_ADDRESS=127.0.0.1 +PORTAL_AUTH_PORT=8621 +PORTAL_AUTH_SESSION_SECONDS=43200 +PORTAL_AUTH_REMEMBER_SECONDS=604800 + +# Product display +AGENT_CONSOLE_NAME=HMM AI 업무 에이전트 +AGENT_CONSOLE_SHORT_NAME=HMM +AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트 +AGENT_CONSOLE_PRIMARY_COLOR=#004b87 + +# Local conversation store +AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH=data/ai_web_agent_console_chat.sqlite3 + +# Optional audit DB. Password and Wallet remain outside Git. +AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE=.env +AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER=ADMIN +AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD= +AI_WEB_AGENT_CONSOLE_AUDIT_DSN= +AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR= +AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD= + +# Optional query contract override +AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH=config/hmm_hr_query_contracts.json diff --git a/ai-web-agent-console/README.md b/ai-web-agent-console/README.md new file mode 100644 index 0000000..799316d --- /dev/null +++ b/ai-web-agent-console/README.md @@ -0,0 +1,57 @@ +# AI Web Agent Console + +HMM 업무 데이터를 MCP 도구로 조회하고 OCI Generative AI로 답변을 구성하는 Streamlit +애플리케이션이다. 과거 PoC 번호나 소스 추출 날짜는 애플리케이션 이름과 패키지 경계로 +사용하지 않는다. + +## 구조 + +```text +ai-web-agent-console/ +├── app.py Streamlit 실행 진입점 +├── ai_web_agent_console/ 인증, 화면, MCP, 모델, 질의 계약 모듈 +├── config/ 제품·모델·MCP·시나리오 JSON +├── tests/ Python 회귀 테스트 +├── .env.example 환경변수 예시 +└── requirements.txt +``` + +## 실행 + +```bash +cd ai-web-agent-console +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/streamlit run app.py --server.address 0.0.0.0 --server.port 8622 +``` + +인증 게이트웨이는 같은 디렉토리에서 다음과 같이 실행한다. + +```bash +.venv/bin/python -m ai_web_agent_console.auth_gateway +``` + +## 설정 + +- `config/app_profile.json`: 제품명, 설명, 아이콘, 색상 +- `config/mcp_servers.json`: MCP endpoint와 허용 도구 +- `config/model_profiles.json`: OCI Generative AI 모델 profile +- `config/vpd_token_presets.json`: 데모 사용자와 토큰 환경변수 매핑 +- `config/hmm_demo_scenarios.json`: 화면의 예제 질문 +- `config/hmm_hr_query_contracts.json`: 질의별 필수 근거와 계산·시간 규칙 + +실제 토큰, DB 비밀번호, Wallet, OCI private key는 Git에 넣지 않는다. 데모 사용자 JSON은 +`HMM_MCP_BEARER_TOKEN` 같은 환경변수 이름만 참조한다. + +## 검증 + +```bash +python3 -m compileall -q app.py ai_web_agent_console tests +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +## 운영 호환성 + +운영 서버의 물리 경로 `/opt/hmm-poc4`와 기존 SQLite 테이블명은 대화 이력과 서비스 전환 +호환을 위해 유지할 수 있다. Git 폴더, Python 패키지와 실행 명령은 이 문서의 새 이름만 +사용한다. diff --git a/poc4_active_source_20260714/src/agent_console/__init__.py b/ai-web-agent-console/ai_web_agent_console/__init__.py similarity index 100% rename from poc4_active_source_20260714/src/agent_console/__init__.py rename to ai-web-agent-console/ai_web_agent_console/__init__.py diff --git a/ai-web-agent-console/ai_web_agent_console/audit.py b/ai-web-agent-console/ai_web_agent_console/audit.py new file mode 100644 index 0000000..b37760c --- /dev/null +++ b/ai-web-agent-console/ai_web_agent_console/audit.py @@ -0,0 +1,190 @@ +"""Reusable audit-tab renderer with data loaders supplied by the application.""" + +from __future__ import annotations + +from typing import Any, Callable + + +AuditInventoryLoader = Callable[[], list[dict[str, Any]]] +AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]] + + +def render_hmm_audit_tab( + st: Any, + inventory_loader: AuditInventoryLoader, + events_loader: AuditEventsLoader, + error_type: type[Exception], +) -> None: + """Render HMM access audit data without owning DB connection details.""" + + st.markdown( + '
' + '감사로그 ( HMM 접근 관리 )' + '
', + unsafe_allow_html=True, + ) + st.markdown( + '
' + 'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. ' + '이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.' + '
', + 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('
조회 조건
', 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('
', 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( + '
이벤트 유형 현황
' + f'
현재 기록된 이벤트 유형 {len(inventory)}개 · ' + '유형별 누적 건수와 최근 발생 시각을 확인합니다.
', + 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( + '
감사 이벤트
' + '
최신 이벤트부터 표시합니다. ' + '처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.
', + 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)), + ) diff --git a/ai-web-agent-console/ai_web_agent_console/auth_gateway.py b/ai-web-agent-console/ai_web_agent_console/auth_gateway.py new file mode 100644 index 0000000..03fd632 --- /dev/null +++ b/ai-web-agent-console/ai_web_agent_console/auth_gateway.py @@ -0,0 +1,563 @@ +"""Small localhost authentication service for the HMM Streamlit portal. + +Nginx owns the public security boundary. This module validates the existing +PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request +subrequests. Authentication values are never accepted from a URL. +""" + +from __future__ import annotations + +import base64 +import binascii +from collections import defaultdict, deque +from dataclasses import dataclass +from datetime import datetime, timezone +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import hashlib +import hmac +import html +import json +import logging +import os +import secrets +import threading +import time +from typing import Deque +from urllib.parse import parse_qs, urlsplit + + +def _environment_value(*names: str, default: str = "") -> str: + for name in names: + value = os.environ.get(name) + if value is not None and value.strip(): + return value.strip() + return default + + +LOG = logging.getLogger("hmm_portal_auth") +SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION" +CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF" +SESSION_TOKEN_VERSION = 2 +MAX_REQUEST_BYTES = 8_192 +MAX_FIELD_CHARS = 200 + + +@dataclass(frozen=True) +class AuthConfig: + username: str + password_pbkdf2: str + cookie_secret: str + bind_address: str = "127.0.0.1" + port: int = 8621 + session_seconds: int = 12 * 60 * 60 + remember_seconds: int = 7 * 24 * 60 * 60 + product_name: str = "HMM AI 업무 에이전트" + login_title: str = "HMM AI 업무 에이전트" + login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다." + login_footer: str = "승인된 사용자만 접속할 수 있습니다." + primary_color: str = "#004b87" + + @classmethod + def from_environment(cls) -> "AuthConfig": + config = cls( + username=_environment_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER" + ), + password_pbkdf2=_environment_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2", + "POC4_LOGIN_PASSWORD_PBKDF2", + ), + cookie_secret=_environment_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET", + "POC4_LOGIN_COOKIE_SECRET", + ), + bind_address=os.environ.get( + "PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1" + ).strip(), + port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")), + session_seconds=int( + os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60)) + ), + remember_seconds=int( + os.environ.get( + "PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60) + ) + ), + product_name=os.environ.get( + "AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트" + ).strip(), + login_title=os.environ.get( + "AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트" + ).strip(), + login_description=os.environ.get( + "AGENT_CONSOLE_LOGIN_DESCRIPTION", + "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.", + ).strip(), + login_footer=os.environ.get( + "AGENT_CONSOLE_LOGIN_FOOTER", + "승인된 사용자만 접속할 수 있습니다.", + ).strip(), + primary_color=os.environ.get( + "AGENT_CONSOLE_PRIMARY_COLOR", "#004b87" + ).strip(), + ) + config.validate() + return config + + def validate(self) -> None: + if not self.username or not self.password_pbkdf2: + raise ValueError("AI web agent console login credentials are not configured") + if len(self.cookie_secret.encode("utf-8")) < 32: + raise ValueError( + "AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes" + ) + if self.bind_address not in {"127.0.0.1", "::1"}: + raise ValueError("Portal authentication service must bind to loopback") + if not 1 <= self.port <= 65535: + raise ValueError("PORTAL_AUTH_PORT is invalid") + if not 300 <= self.session_seconds <= 24 * 60 * 60: + raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range") + if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60: + raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range") + + +@dataclass(frozen=True) +class AuthenticatedSession: + username: str + expires_at: int + + +class SessionTokenCodec: + def __init__(self, secret: str): + self._secret = secret.encode("utf-8") + + def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str: + issued_at = int(time.time()) if now is None else now + payload = { + "v": SESSION_TOKEN_VERSION, + "u": username, + "i": issued_at, + "e": issued_at + lifetime_seconds, + "n": secrets.token_urlsafe(18), + } + encoded = _base64url_encode( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ) + signature = hmac.new( + self._secret, encoded.encode("ascii"), hashlib.sha256 + ).hexdigest() + return f"{encoded}.{signature}" + + def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None: + if not token or len(token) > 2048: + return None + current_time = int(time.time()) if now is None else now + try: + encoded, supplied_signature = token.split(".", 1) + expected_signature = hmac.new( + self._secret, encoded.encode("ascii"), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(supplied_signature, expected_signature): + return None + payload = json.loads(_base64url_decode(encoded).decode("utf-8")) + version = int(payload["v"]) + username = str(payload["u"]) + issued_at = int(payload["i"]) + expires_at = int(payload["e"]) + except ( + binascii.Error, + KeyError, + TypeError, + ValueError, + UnicodeDecodeError, + json.JSONDecodeError, + ): + return None + if version != SESSION_TOKEN_VERSION: + return None + if issued_at > current_time + 30 or expires_at <= current_time: + return None + if expires_at - issued_at > 30 * 24 * 60 * 60: + return None + if not hmac.compare_digest(username, expected_username): + return None + return AuthenticatedSession(username=username, expires_at=expires_at) + + +class LoginAttemptLimiter: + def __init__(self, maximum_failures: int = 5, window_seconds: int = 300): + self._maximum_failures = maximum_failures + self._window_seconds = window_seconds + self._failures: dict[str, Deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def blocked(self, key: str, now: float | None = None) -> bool: + current_time = time.monotonic() if now is None else now + with self._lock: + failures = self._failures[key] + self._prune(failures, current_time) + return len(failures) >= self._maximum_failures + + def record_failure(self, key: str, now: float | None = None) -> None: + current_time = time.monotonic() if now is None else now + with self._lock: + failures = self._failures[key] + self._prune(failures, current_time) + failures.append(current_time) + + def reset(self, key: str) -> None: + with self._lock: + self._failures.pop(key, None) + + def _prune(self, failures: Deque[float], now: float) -> None: + cutoff = now - self._window_seconds + while failures and failures[0] < cutoff: + failures.popleft() + + +def password_matches(password: str, encoded_password: str) -> bool: + try: + scheme, iterations_text, salt_hex, expected_hex = encoded_password.split( + "$", 3 + ) + iterations = int(iterations_text) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(expected_hex) + except (TypeError, ValueError): + return False + if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000: + return False + candidate = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, iterations + ) + return hmac.compare_digest(candidate, expected) + + +def session_cookie_header(token: str, max_age: int | None) -> str: + attributes = [ + f"{SESSION_COOKIE_NAME}={token}", + "Path=/", + "Secure", + "HttpOnly", + "SameSite=Lax", + ] + if max_age is not None: + attributes.append(f"Max-Age={max_age}") + return "; ".join(attributes) + + +def clear_session_cookie_header() -> str: + return ( + f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; " + "Secure; HttpOnly; SameSite=Lax" + ) + + +def csrf_cookie_header(value: str, max_age: int = 600) -> str: + return ( + f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; " + "Secure; HttpOnly; SameSite=Strict" + ) + + +def clear_csrf_cookie_header() -> str: + return ( + f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; " + "Secure; HttpOnly; SameSite=Strict" + ) + + +def _base64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _base64url_decode(value: str) -> bytes: + padded = value + "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(padded) + + +def _cookie_value(cookie_header: str, name: str) -> str: + try: + cookies = SimpleCookie() + cookies.load(cookie_header) + morsel = cookies.get(name) + return morsel.value if morsel is not None else "" + except (KeyError, TypeError): + return "" + + +def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes: + error_html = ( + f'' + if error + else "" + ) + return f""" + + + + + {html.escape(config.product_name)} + + + +
+
HMM
+

{html.escape(config.login_title)}

+

{html.escape(config.login_description)}

+ {error_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() diff --git a/ai-web-agent-console/ai_web_agent_console/mcp_result.py b/ai-web-agent-console/ai_web_agent_console/mcp_result.py new file mode 100644 index 0000000..a35ec02 --- /dev/null +++ b/ai-web-agent-console/ai_web_agent_console/mcp_result.py @@ -0,0 +1,80 @@ +"""Pure helpers for MCP result envelopes used by the Streamlit console.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def response_payload(value: Any) -> Mapping[str, Any]: + """Return the business payload from a direct or nested MCP response.""" + + if not isinstance(value, Mapping): + return {} + nested = value.get("response") + return nested if isinstance(nested, Mapping) else value + + +def text_result(value: Any) -> str: + """Return a textual `result` field without stringifying other structures.""" + + result = response_payload(value).get("result") + return result.strip() if isinstance(result, str) else "" + + +def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]: + """Build a safe UI summary for status/result-style compatibility tools.""" + + payload = response_payload(value) + summary: dict[str, Any] = {} + for key in ("status", "success", "error", "errorCode", "errorMessage"): + item = payload.get(key) + if item not in (None, "", []): + summary[key] = item + result = text_result(value) + if result: + summary["result_chars"] = len(result) + summary["result_excerpt"] = result[:excerpt_chars] + ( + "..." if len(result) > excerpt_chars else "" + ) + return summary + + +def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]: + """Preserve bounded textual policy/data evidence for final answer synthesis.""" + + payload = response_payload(value) + evidence: dict[str, Any] = {} + for key in ("status", "success", "error", "errorCode", "errorMessage"): + item = payload.get(key) + if item not in (None, "", []): + evidence[key] = item + result = text_result(value) + if result: + evidence["result"] = result[:max_chars] + ( + "..." if len(result) > max_chars else "" + ) + evidence["result_chars"] = len(result) + return evidence + + +def has_actionable_text_result(value: Any) -> bool: + """Return whether a textual result contains evidence worth stopping on.""" + + result = text_result(value) + if not result: + return False + normalized = " ".join(result.casefold().split()) + return not any( + marker in normalized + for marker in ("no data found", "no evidence found", "error:") + ) + + +__all__ = [ + "has_actionable_text_result", + "response_payload", + "status_result_evidence", + "status_result_summary", + "text_result", +] diff --git a/poc4_active_source_20260714/src/mcp_tool_router.py b/ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py similarity index 84% rename from poc4_active_source_20260714/src/mcp_tool_router.py rename to ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py index 3e09962..be0edae 100644 --- a/poc4_active_source_20260714/src/mcp_tool_router.py +++ b/ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py @@ -10,11 +10,11 @@ from dataclasses import dataclass import json from typing import Any, Mapping -from src.oci_genai_sdk import ( +from ai_web_agent_console.oci_genai_sdk import ( build_oci_genai_completion_client, temperature_for_model_profile, ) -from src.poc3.model_registry import resolve_model_profile +from ai_web_agent_console.model_registry import resolve_model_profile @dataclass(frozen=True) @@ -154,26 +154,32 @@ def build_mcp_tool_arguments( if not isinstance(properties, Mapping): properties = {} - if tool.name == preferred_tool: - return {"prompt": question, "limit": limit} - if "prompt" in properties: - args: dict[str, Any] = {"prompt": question} - if "limit" in properties: - args["limit"] = limit - elif "max_rows" in properties: - args["max_rows"] = limit - return args - if "question" in properties: - args = {"question": question} - if "max_rows" in properties: - args["max_rows"] = limit - elif "limit" in properties: - args["limit"] = limit - return args - if "query" in properties: - args = {"query": question} + # A server's default/preferred tool still has to obey its discovered schema. + # HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape + # makes an otherwise valid tool fail argument validation. + del preferred_tool + input_name = next( + (name for name in ("prompt", "question", "query", "term", "text") if name in properties), + "", + ) + if not input_name: + required = tool.schema.get("required") + if isinstance(required, list): + input_name = next( + ( + str(name) + for name in required + if isinstance(properties.get(str(name)), Mapping) + and properties[str(name)].get("type") == "string" + ), + "", + ) + if input_name: + args: dict[str, Any] = {input_name: question} if "max_evidence" in properties: args["max_evidence"] = min(limit, 10) + elif "max_rows" in properties: + args["max_rows"] = limit elif "limit" in properties: args["limit"] = limit return args diff --git a/poc4_active_source_20260714/src/poc3/model_registry.py b/ai-web-agent-console/ai_web_agent_console/model_registry.py similarity index 83% rename from poc4_active_source_20260714/src/poc3/model_registry.py rename to ai-web-agent-console/ai_web_agent_console/model_registry.py index ee9adf6..16ed237 100644 --- a/poc4_active_source_20260714/src/poc3/model_registry.py +++ b/ai-web-agent-console/ai_web_agent_console/model_registry.py @@ -1,7 +1,7 @@ -"""8512/8513 전용 PoC_3 model profile registry. +"""8512/8513 전용 AI Web Agent Console model profile registry. 이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며 -``POC3_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재 +``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재 MCP 실행 경로는 계속 ``mock``일 수 있다. """ @@ -15,11 +15,11 @@ import re from typing import Any, Mapping, Optional -ROOT = Path(__file__).resolve().parents[2] -REGISTRY_PATH = ROOT / "config" / "poc3_model_profiles.json" +ROOT = Path(__file__).resolve().parents[1] +REGISTRY_PATH = ROOT / "config" / "model_profiles.json" DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci" -MODEL_PROFILE_ENV = "POC3_MODEL_PROFILE" -MODEL_PROFILE_DEFAULT_ENV = "POC3_MODEL_PROFILE_DEFAULT" +MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE" +MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT" EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b") MODEL_PROFILE_ALIASES = { "gpt54_mini": "gpt54_mini_oci", @@ -84,36 +84,36 @@ _REQUIRED_PROFILE_FIELDS = ( "poc2_select_ai_profile", "poc2_native_agent_team", "verification_status", - "default_for_poc3", + "default_for_console", "source_tag", "notes", ) _PROFILE_ROUTE_ENV_KEYS = { "gpt55_oci": ( - "POC3_LLM_GPT55_OCI_MODEL_ID", - "POC3_LLM_GPT55_OCI_REGION", - "POC3_LLM_GPT55_OCI_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT", ), "gpt54_mini_oci": ( - "POC3_LLM_GPT54_MINI_OCI_MODEL_ID", - "POC3_LLM_GPT54_MINI_OCI_REGION", - "POC3_LLM_GPT54_MINI_OCI_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT", ), "grok43": ( - "POC3_LLM_GROK43_MODEL_ID", - "POC3_LLM_GROK43_REGION", - "POC3_LLM_GROK43_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT", ), "llama4_maverick": ( - "POC3_LLM_LLAMA4_MAVERICK_MODEL_ID", - "POC3_LLM_LLAMA4_MAVERICK_REGION", - "POC3_LLM_LLAMA4_MAVERICK_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT", ), "llama33_70b": ( - "POC3_LLM_LLAMA33_70B_MODEL_ID", - "POC3_LLM_LLAMA33_70B_REGION", - "POC3_LLM_LLAMA33_70B_ENDPOINT", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION", + "AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT", ), } @@ -154,7 +154,7 @@ class ModelProfile: poc2_select_ai_profile: str poc2_native_agent_team: str verification_status: str - default_for_poc3: bool + default_for_console: bool source_tag: str notes: str display_order: int = 999 @@ -163,9 +163,9 @@ class ModelProfile: def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile": missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value] if missing: - raise ValueError("PoC_3 model profile fields are missing") - if not isinstance(value.get("default_for_poc3"), bool): - raise ValueError("default_for_poc3 must be boolean") + raise ValueError("AI Web Agent Console model profile fields are missing") + if not isinstance(value.get("default_for_console"), bool): + raise ValueError("default_for_console must be boolean") order = value.get("display_order", 999) if isinstance(order, bool) or not isinstance(order, int) or order < 0: raise ValueError("model profile display_order is invalid") @@ -191,7 +191,7 @@ class ModelProfile: .strip() .upper(), verification_status=str(value["verification_status"]).strip().upper(), - default_for_poc3=value["default_for_poc3"], + default_for_console=value["default_for_console"], source_tag=str(value["source_tag"]).strip(), notes=str(value["notes"]).strip(), display_order=order, @@ -276,7 +276,7 @@ class ModelProfile: "poc2_select_ai_profile": self.poc2_select_ai_profile, "poc2_native_agent_team": self.poc2_native_agent_team, "verification_status": self.verification_status, - "default_for_poc3": self.default_for_poc3, + "default_for_console": self.default_for_console, "source_tag": self.source_tag, "notes": self.notes, } @@ -284,7 +284,7 @@ class ModelProfile: @dataclass(frozen=True) class ModelProfileRegistry: - """검증된 PoC_3 model profile 집합.""" + """검증된 AI Web Agent Console model profile 집합.""" profiles: tuple[ModelProfile, ...] default_model_profile: str @@ -338,32 +338,32 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry: try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - raise ValueError("PoC_3 model profile registry cannot be loaded") from exc + raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc if not isinstance(payload, Mapping): - raise ValueError("PoC_3 model profile registry must be an object") + raise ValueError("AI Web Agent Console model profile registry must be an object") raw_profiles = payload.get("profiles") if not isinstance(raw_profiles, list) or not raw_profiles: - raise ValueError("PoC_3 model profile registry has no profiles") + raise ValueError("AI Web Agent Console model profile registry has no profiles") profiles = tuple( ModelProfile.from_mapping(item) for item in raw_profiles if isinstance(item, Mapping) ) if len(profiles) != len(raw_profiles): - raise ValueError("PoC_3 model profile registry contains an invalid profile") + raise ValueError("AI Web Agent Console model profile registry contains an invalid profile") keys = tuple(item.model_key for item in profiles) if len(set(keys)) != len(keys): - raise ValueError("PoC_3 model profile keys must be unique") + raise ValueError("AI Web Agent Console model profile keys must be unique") if len({item.display_name for item in profiles}) != len(profiles): - raise ValueError("PoC_3 model profile display names must be unique") - defaults = tuple(item.model_key for item in profiles if item.default_for_poc3) + raise ValueError("AI Web Agent Console model profile display names must be unique") + defaults = tuple(item.model_key for item in profiles if item.default_for_console) configured_default = str(payload.get("default_model_profile") or "").strip().lower() if defaults != (configured_default,): - raise ValueError("PoC_3 model profile default is inconsistent") + raise ValueError("AI Web Agent Console model profile default is inconsistent") if configured_default != DEFAULT_MODEL_PROFILE_KEY: - raise ValueError("PoC_3 GPT-5.5 default contract is not satisfied") + raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied") if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys): - raise ValueError("existing PoC_3 selector models are missing") + raise ValueError("existing AI Web Agent Console selector models are missing") actual_answer_routes = { item.model_key: ( item.model_id, @@ -374,16 +374,16 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry: for item in profiles } if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES: - raise ValueError("PoC_3 answer model route mapping is inconsistent") + raise ValueError("AI Web Agent Console answer model route mapping is inconsistent") if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT: raise ValueError("PoC_2 source commit is inconsistent") if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles): raise ValueError("PoC_2 source tag is inconsistent") if payload.get("schema_version") != 1: - raise ValueError("unsupported PoC_3 model profile registry schema") + raise ValueError("unsupported AI Web Agent Console model profile registry schema") registry_name = str(payload.get("registry_name") or "").strip() if not registry_name: - raise ValueError("PoC_3 model profile registry name is missing") + raise ValueError("AI Web Agent Console model profile registry name is missing") return ModelProfileRegistry( profiles=profiles, default_model_profile=configured_default, diff --git a/poc4_active_source_20260714/src/oci_genai_sdk.py b/ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py similarity index 100% rename from poc4_active_source_20260714/src/oci_genai_sdk.py rename to ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/ai-web-agent-console/ai_web_agent_console/presentation.py similarity index 53% rename from poc4_active_source_20260714/src/agent_console/presentation.py rename to ai-web-agent-console/ai_web_agent_console/presentation.py index 2ea8b72..56fac2e 100644 --- a/poc4_active_source_20260714/src/agent_console/presentation.py +++ b/ai-web-agent-console/ai_web_agent_console/presentation.py @@ -27,6 +27,8 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: [data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span, [data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1, [data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3, + [data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt, + [data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote, [data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea, section[data-testid="stSidebar"] * {{ color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }} @@ -34,13 +36,57 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: background:#fff !important; border:1px solid var(--console-border) !important; border-radius:4px !important; box-shadow:none !important; }} div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{ + background:#fff !important; color:var(--console-text) !important; + border:1px solid var(--console-border) !important; border-radius:4px !important; box-shadow:none !important; }} + div[data-testid="stButton"] > button *, + div[data-testid="stFormSubmitButton"] > button * {{ + color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }} div[data-testid="stButton"] > button[kind="primary"], div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{ background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }} div[data-testid="stButton"] > button[kind="primary"] *, div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{ color:#fff !important; -webkit-text-fill-color:#fff !important; }} + /* Streamlit JSON/code components otherwise retain a dark-surface theme while + the console text is forced dark, making MCP details unreadable. */ + [data-testid="stJson"], [data-testid="stJson"] > div, + [data-testid="stCodeBlock"], [data-testid="stCodeBlock"] > div, + [data-testid="stCode"], [data-testid="stCode"] > div {{ + background:#f6f8fa !important; color:var(--console-text) !important; + border-color:var(--console-border) !important; }} + [data-testid="stJson"] *, [data-testid="stCodeBlock"] *, [data-testid="stCode"] *, + [data-testid="stJson"] pre, [data-testid="stCodeBlock"] pre, [data-testid="stCode"] pre {{ + color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; }} + [data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{ + background:#fff !important; border-color:var(--console-border) !important; }} + [data-testid="stExpander"] summary {{ + background:#f6f8fa !important; color:var(--console-text) !important; + border-color:var(--console-border) !important; }} + [data-testid="stExpander"] summary * {{ + color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; }} + [data-baseweb="tab-list"], [data-testid="stTabs"] [role="tablist"] {{ + border-bottom:1px solid var(--console-border) !important; }} + [data-baseweb="tab-list"] [role="tab"], [data-testid="stTab"] {{ + background:#fff !important; color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; + border-radius:4px 4px 0 0 !important; }} + [data-baseweb="tab-list"] [role="tab"] *, [data-testid="stTab"] * {{ + color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; }} + [data-baseweb="tab-list"] [role="tab"]:hover, [data-testid="stTab"]:hover {{ + background:#f6f8fa !important; }} + [data-baseweb="tab-list"] [role="tab"][aria-selected="true"], + [data-testid="stTab"][aria-selected="true"] {{ + color:var(--console-primary) !important; + -webkit-text-fill-color:var(--console-primary) !important; + border-bottom:3px solid var(--console-primary) !important; }} + [data-baseweb="tab-list"] [role="tab"][aria-selected="true"] *, + [data-testid="stTab"][aria-selected="true"] * {{ + color:var(--console-primary) !important; + -webkit-text-fill-color:var(--console-primary) !important; }} .console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }} .console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }} .console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }} @@ -48,6 +94,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: .st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }} .console-login {{ text-align:left; }} .console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }} + a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem; + margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; + border:1px solid var(--console-border); border-radius:4px; + text-align:center; text-decoration:none; font-weight:700; }} + a.console-logout-button:hover {{ background:#f6f8fa; }} @media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }} """, diff --git a/poc4_active_source_20260714/src/agent_console/profile.py b/ai-web-agent-console/ai_web_agent_console/profile.py similarity index 100% rename from poc4_active_source_20260714/src/agent_console/profile.py rename to ai-web-agent-console/ai_web_agent_console/profile.py diff --git a/ai-web-agent-console/ai_web_agent_console/query_contracts.py b/ai-web-agent-console/ai_web_agent_console/query_contracts.py new file mode 100644 index 0000000..8427a73 --- /dev/null +++ b/ai-web-agent-console/ai_web_agent_console/query_contracts.py @@ -0,0 +1,407 @@ +"""Configuration-driven query and answer evidence contracts.""" + +from __future__ import annotations + +import ast +from decimal import Decimal, InvalidOperation +from functools import lru_cache +import json +import os +from pathlib import Path +import re +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json" + + +def _contract_file() -> Path: + configured = str( + os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH") + or os.getenv("POC4_QUERY_CONTRACTS_PATH") + or "" + ).strip() + if not configured: + return DEFAULT_CONTRACT_FILE + path = Path(configured).expanduser() + return path if path.is_absolute() else ROOT / path + + +@lru_cache(maxsize=4) +def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]: + path = Path(path_text) + if not path.exists(): + return () + value = json.loads(path.read_text(encoding="utf-8")) + contracts = value.get("contracts") if isinstance(value, Mapping) else None + if not isinstance(contracts, list): + raise ValueError("query contracts must contain a contracts array") + return tuple(item for item in contracts if isinstance(item, Mapping)) + + +def load_query_contracts() -> tuple[Mapping[str, Any], ...]: + return _load_contract_file(str(_contract_file())) + + +def matching_query_contracts( + question: str, + tool_name: str, +) -> tuple[Mapping[str, Any], ...]: + normalized_question = " ".join(str(question or "").casefold().split()) + normalized_tool = str(tool_name or "").strip() + selected: list[Mapping[str, Any]] = [] + for contract in load_query_contracts(): + tools = contract.get("applies_to_tools") + if isinstance(tools, list) and normalized_tool not in { + str(item) for item in tools + }: + continue + intent = contract.get("intent_match") + if not isinstance(intent, Mapping): + continue + subject_terms = [ + str(item).casefold() + for item in intent.get("subject_terms_any", []) + if str(item).strip() + ] + action_terms = [ + str(item).casefold() + for item in intent.get("action_terms_any", []) + if str(item).strip() + ] + if subject_terms and not any( + term in normalized_question for term in subject_terms + ): + continue + if action_terms and not any( + term in normalized_question for term in action_terms + ): + continue + selected.append(contract) + return tuple(selected) + + +def query_contract_guidance(question: str, tool_name: str) -> str: + contracts = matching_query_contracts(question, tool_name) + if not contracts: + return "" + return json.dumps( + {"query_contracts": contracts}, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def append_query_contract_guidance( + tool_query: str, + *, + original_question: str, + tool_name: str, +) -> str: + guidance = query_contract_guidance(original_question, tool_name) + normalized = str(tool_query or "").strip() + if not guidance: + return normalized + return ( + f"{normalized}\n" + "다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. " + f"계약: {guidance}" + ) + + +def _json_data_result(value: str) -> Any: + marker = "DATA_RESULT" + position = value.find(marker) + if position < 0: + return None + remainder = value[position + len(marker) :] + object_position = remainder.find("{") + array_position = remainder.find("[") + positions = [ + candidate + for candidate in (object_position, array_position) + if candidate >= 0 + ] + if not positions: + return None + candidate = remainder[min(positions) :] + try: + parsed, _ = json.JSONDecoder().raw_decode(candidate) + except (TypeError, ValueError, json.JSONDecodeError): + return None + return parsed + + +def _evidence_rows(value: Any) -> list[Mapping[str, Any]]: + rows: list[Mapping[str, Any]] = [] + if isinstance(value, Mapping): + rows.append(value) + for item in value.values(): + rows.extend(_evidence_rows(item)) + elif isinstance(value, list): + for item in value: + rows.extend(_evidence_rows(item)) + elif isinstance(value, str): + parsed = _json_data_result(value) + if parsed is not None: + rows.extend(_evidence_rows(parsed)) + return rows + + +def _decimal(value: Any) -> Decimal: + if isinstance(value, bool) or value is None: + raise InvalidOperation + return Decimal(str(value)) + + +def _evaluate_contract_expression( + expression: str, + row: Mapping[str, Any], +) -> Decimal: + tree = ast.parse(expression, mode="eval") + + def evaluate(node: ast.AST) -> Decimal: + if isinstance(node, ast.Expression): + return evaluate(node.body) + if isinstance(node, ast.Name): + key = node.id.casefold() + if key not in row: + raise InvalidOperation + return _decimal(row[key]) + if isinstance(node, ast.Constant): + return _decimal(node.value) + if isinstance(node, ast.UnaryOp) and isinstance( + node.op, (ast.UAdd, ast.USub) + ): + value = evaluate(node.operand) + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div) + ): + left = evaluate(node.left) + right = evaluate(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + return left / right + raise ValueError("unsupported contract expression") + + return evaluate(tree) + + +def evidence_contract_report( + contracts: tuple[Mapping[str, Any], ...], + evidence: Any, +) -> list[dict[str, Any]]: + rows = [ + {str(key).strip().casefold(): value for key, value in row.items()} + for row in _evidence_rows(evidence) + ] + reports: list[dict[str, Any]] = [] + for contract in contracts: + data_contract = contract.get("data_contract") + if not isinstance(data_contract, Mapping): + continue + required = [ + str(item).strip().casefold() + for item in data_contract.get("required_fields", []) + if str(item).strip() + ] + matching_row = next( + (row for row in rows if required and all(key in row for key in required)), + None, + ) + observed = sorted( + { + key + for row in rows + for key in row + if not required or key in required + } + ) + missing = [ + key.upper() + for key in required + if matching_row is None or key not in matching_row + ] + computed_checks: list[dict[str, Any]] = [] + computed_fields = data_contract.get("computed_fields") + if matching_row is not None and isinstance(computed_fields, Mapping): + for field, definition in computed_fields.items(): + normalized_field = str(field).strip().casefold() + expression = ( + str(definition.get("expression") or "").strip() + if isinstance(definition, Mapping) + else "" + ) + if not normalized_field or not expression: + continue + try: + actual = _decimal(matching_row.get(normalized_field)) + expected = _evaluate_contract_expression( + expression, + matching_row, + ) + matches = actual == expected + computed_checks.append( + { + "field": normalized_field.upper(), + "expression": expression, + "actual": str(actual), + "expected": str(expected), + "satisfied": matches, + } + ) + except (InvalidOperation, ValueError, ZeroDivisionError): + computed_checks.append( + { + "field": normalized_field.upper(), + "expression": expression, + "satisfied": False, + } + ) + calculations_satisfied = all( + bool(check.get("satisfied")) for check in computed_checks + ) + temporal_checks: list[dict[str, Any]] = [] + temporal_contract = contract.get("temporal_contract") + if matching_row is not None and isinstance(temporal_contract, Mapping): + status_fields = [ + str(item).strip().casefold() + for item in temporal_contract.get("period_status_fields", []) + if str(item).strip() + ] + past_status = str( + temporal_contract.get("past_period_status") or "" + ).strip() + observed_status = next( + ( + str(matching_row.get(field) or "").strip() + for field in status_fields + if str(matching_row.get(field) or "").strip() + ), + "", + ) + if observed_status == past_status and past_status: + decision_field = str( + temporal_contract.get("past_period_decision_field") or "" + ).strip().casefold() + decision_value = str( + temporal_contract.get("past_period_decision_value") or "" + ).strip() + actual_decision = str( + matching_row.get(decision_field) or "" + ).strip() + temporal_checks.append( + { + "check": "past_period_decision", + "field": decision_field.upper(), + "expected": decision_value, + "actual": actual_decision, + "satisfied": bool( + decision_field + and decision_value + and actual_decision == decision_value + ), + } + ) + forbidden_patterns = [ + str(item).strip() + for item in temporal_contract.get( + "forbidden_past_period_decision_field_patterns", [] + ) + if str(item).strip() + ] + forbidden_fields = sorted( + key.upper() + for key in matching_row + if any( + re.search(pattern, key, flags=re.IGNORECASE) + for pattern in forbidden_patterns + ) + ) + temporal_checks.append( + { + "check": "no_past_period_yes_no_decision", + "forbidden_fields": forbidden_fields, + "satisfied": not forbidden_fields, + } + ) + required_counts = [ + str(item).strip().casefold() + for item in temporal_contract.get( + "validate_stated_days_against", [] + ) + if str(item).strip() + ] + temporal_checks.append( + { + "check": "date_range_counts", + "required_fields": [ + field.upper() for field in required_counts + ], + "missing_fields": [ + field.upper() + for field in required_counts + if field not in matching_row + ], + "satisfied": all( + field in matching_row for field in required_counts + ), + } + ) + temporal_satisfied = all( + bool(check.get("satisfied")) for check in temporal_checks + ) + reports.append( + { + "contract_id": str(contract.get("id") or ""), + "satisfied": bool( + matching_row is not None + and not missing + and calculations_satisfied + and temporal_satisfied + ), + "required_fields": [key.upper() for key in required], + "observed_required_fields": [key.upper() for key in observed], + "missing_fields": missing, + "computed_field_checks": computed_checks, + "temporal_contract_checks": temporal_checks, + "validated_record": ( + { + key.upper(): matching_row.get(key) + for key in required + } + if matching_row is not None + else {} + ), + } + ) + return reports + + +def missing_evidence_message( + contracts: tuple[Mapping[str, Any], ...], +) -> str: + for contract in contracts: + answer_contract = contract.get("answer_contract") + if not isinstance(answer_contract, Mapping): + continue + message = str(answer_contract.get("missing_evidence_message") or "").strip() + if message: + return message + return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다." + + +__all__ = [ + "append_query_contract_guidance", + "evidence_contract_report", + "load_query_contracts", + "matching_query_contracts", + "missing_evidence_message", + "query_contract_guidance", +] diff --git a/poc4_active_source_20260714/src/poc3/questions.py b/ai-web-agent-console/ai_web_agent_console/questions.py similarity index 97% rename from poc4_active_source_20260714/src/poc3/questions.py rename to ai-web-agent-console/ai_web_agent_console/questions.py index da01a1e..ba6bd1b 100644 --- a/poc4_active_source_20260714/src/poc3/questions.py +++ b/ai-web-agent-console/ai_web_agent_console/questions.py @@ -1,4 +1,4 @@ -"""PoC_3 preset catalog와 현재 질문 기반의 결정적 intent router.""" +"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router.""" from __future__ import annotations @@ -90,7 +90,7 @@ COMMON_DEMO_QUESTIONS = ( _QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS} if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS): - raise RuntimeError("duplicate PoC_3 demo question id") + raise RuntimeError("duplicate AI Web Agent Console demo question id") def question_by_id(question_id: str) -> DemoQuestion: @@ -100,7 +100,7 @@ def question_by_id(question_id: str) -> DemoQuestion: try: return _QUESTION_BY_ID[normalized] except KeyError: - raise ValueError("unknown PoC_3 demo question id") from None + raise ValueError("unknown AI Web Agent Console demo question id") from None def normalize_scenario_id(value: object) -> str | None: diff --git a/poc4_active_source_20260714/src/poc4/scenarios.py b/ai-web-agent-console/ai_web_agent_console/scenarios.py similarity index 100% rename from poc4_active_source_20260714/src/poc4/scenarios.py rename to ai-web-agent-console/ai_web_agent_console/scenarios.py diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/ai-web-agent-console/app.py similarity index 80% rename from poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py rename to ai-web-agent-console/app.py index 1f5b94c..26d6e08 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/ai-web-agent-console/app.py @@ -1,7 +1,7 @@ -"""Minimal MCP discovery UI for PoC4. +"""HMM AI Web Agent Console Streamlit entrypoint. Run: - streamlit run apps/poc4/mcp_discovery_ui.py --server.port 8622 + streamlit run app.py --server.port 8622 This screen intentionally does not use the existing LangGraph/actor-context pipeline. It discovers MCP tools, selects a query-capable tool, and calls it @@ -10,8 +10,6 @@ with the Bearer token entered on the screen. from __future__ import annotations -import base64 -import binascii import hashlib import hmac import html @@ -31,7 +29,7 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener -ROOT = Path(__file__).resolve().parents[2] +ROOT = Path(__file__).resolve().parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -39,30 +37,43 @@ import streamlit as st import streamlit.components.v1 as components import oracledb -from src.mcp_tool_router import ( +from ai_web_agent_console.mcp_tool_router import ( McpTool, McpToolRouterError, RoutedMcpTool, build_mcp_tool_arguments, route_mcp_tool_across_servers_with_llm, ) -from src.oci_genai_sdk import ( +from ai_web_agent_console.mcp_result import ( + has_actionable_text_result, + status_result_evidence, + status_result_summary, + text_result, +) +from ai_web_agent_console.oci_genai_sdk import ( build_oci_genai_completion_client, temperature_for_model_profile, ) -from src.poc3.model_registry import load_model_registry, resolve_model_profile -from src.agent_console.presentation import ( +from ai_web_agent_console.model_registry import load_model_registry, resolve_model_profile +from ai_web_agent_console.presentation import ( apply_console_theme, render_console_header, render_login_brand, ) -from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile -from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios +from ai_web_agent_console.audit import render_hmm_audit_tab +from ai_web_agent_console.profile import AppProfile, AppProfileError, load_app_profile +from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios +from ai_web_agent_console.query_contracts import ( + append_query_contract_guidance, + evidence_contract_report, + matching_query_contracts, + missing_evidence_message, +) LOG = logging.getLogger(__name__) MCP_PROTOCOL_VERSION = "2025-11-25" -PREFERRED_TOOL = "ords.query.kb_select_ai_vpd" +PREFERRED_TOOL = "search_hr_data" DEFAULT_QUESTION = "" MAX_RESPONSE_BYTES = 1_000_000 MAX_CONVERSATION_MESSAGES = 8 @@ -70,7 +81,10 @@ CHAT_TURNS_PER_PAGE = 3 CHAT_CONTEXT_TURNS = 4 MAX_AGENT_TOOL_STEPS = 4 AGENT_FINAL_ROUTE = "__final__" -COMPLEX_REASONING_MODEL_PROFILE_ENV = "POC4_COMPLEX_REASONING_MODEL_PROFILE" +COMPLEX_REASONING_MODEL_PROFILE_ENV = ( + "AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE" +) +LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV = "POC4_COMPLEX_REASONING_MODEL_PROFILE" DEFAULT_COMPLEX_REASONING_MODEL_PROFILE = "grok43" DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE = "gpt54_mini_oci" DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci" @@ -80,1376 +94,25 @@ VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json" DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json" APP_PROFILE_FILE = ROOT / "config" / "app_profile.json" CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" -DEFAULT_VPD_USER_ID = "FC00789" -VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" +DEFAULT_VPD_USER_ID = "E1001" +VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" -PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" -PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" -PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 -AUDIT_SCHEMA = "POC_2" +PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User" +PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires" AUDIT_DB_ENV_FILE = Path( - os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env") + os.environ.get("AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE") + or os.environ.get("POC4_AUDIT_DB_ENV_FILE") + or str(ENV_FILE) ).expanduser() +DEFAULT_AUDIT_DB_DSN = ( + "(description=(retry_count=3)(retry_delay=1)" + "(address=(protocol=tcps)(port=1521)(host=adb.ap-seoul-1.oraclecloud.com))" + "(connect_data=(service_name=" + "yh0olybn5pqce4n_hmmaipoc_high.adb.oraclecloud.com))" + "(security=(ssl_server_dn_match=yes)))" +) _OPAQUE_BEARER = re.compile(r"^[\x21-\x7e]{1,4096}$") -KB_THEME_CSS = """ - -""" - -HMM_MINIMAL_THEME_CSS = """ - -""" - -HMM_CLEAN_THEME_CSS = """ - -""" - @dataclass(frozen=True) class McpServer: @@ -1471,6 +134,7 @@ class VpdTokenPreset: scope: str token: str = field(repr=False, compare=False) is_default: bool = False + team: str = "" @property def display_label(self) -> str: @@ -1480,7 +144,7 @@ class VpdTokenPreset: self.user_id, self.name, self.role, - self.channel, + self.team or self.channel, self.scope, ) if item @@ -1493,6 +157,7 @@ class VpdTokenPreset: for item in ( self.user_id, self.name, + self.role, ) if item ) @@ -1537,7 +202,10 @@ def _utc_now() -> str: def _chat_db_path() -> Path: - configured = _runtime_env_value("POC4_CHAT_DB_PATH") + configured = _runtime_env_value( + "AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH", + "POC4_CHAT_DB_PATH", + ) if not configured: return CHAT_DB_FILE path = Path(configured).expanduser() @@ -1895,159 +563,64 @@ def _apply_console_theme(profile: AppProfile) -> None: apply_console_theme(st, profile) -def _portal_auth_value(name: str) -> str: - return (os.environ.get(name) or _dotenv_value(name)).strip() +def _portal_auth_value(*names: str) -> str: + for name in names: + value = (os.environ.get(name) or _dotenv_value(name)).strip() + if value: + return value + return "" -def _portal_password_matches(password: str, encoded_password: str) -> bool: +def _proxy_auth_headers() -> tuple[str, int]: 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, + headers = st.context.headers + username = str(headers.get(PORTAL_AUTH_PROXY_USER_HEADER) or "").strip() + expires_at = int( + str(headers.get(PORTAL_AUTH_PROXY_EXPIRY_HEADER) or "0").strip() + ) + except (AttributeError, TypeError, ValueError): + return "", 0 + return username, expires_at + + +def _restore_portal_proxy_session() -> None: + username, expires_at = _proxy_auth_headers() + expected_username = _portal_auth_value( + "AI_WEB_AGENT_CONSOLE_LOGIN_USER", + "POC4_LOGIN_USER", ) - return hmac.compare_digest(candidate, expected) - - -def _portal_auth_configured() -> bool: - return bool( - _portal_auth_value("POC4_LOGIN_USER") - and _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2") + authenticated = bool( + username + and expected_username + and expires_at > int(datetime.now(timezone.utc).timestamp()) + and hmac.compare_digest(username, expected_username) ) - - -def _portal_credentials_are_valid(username: str, password: str) -> bool: - expected_username = _portal_auth_value("POC4_LOGIN_USER") - encoded_password = _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2") - username_matches = hmac.compare_digest(username.strip(), expected_username) - password_matches = _portal_password_matches(password, encoded_password) - return username_matches and password_matches - - -def _portal_remember_secret() -> str: - return _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET") - - -def _portal_remember_token(username: str) -> str: - secret = _portal_remember_secret() - if not secret: - return "" - payload = { - "v": 1, - "u": username.strip(), - "e": int(datetime.now(timezone.utc).timestamp()) + PORTAL_REMEMBER_MAX_AGE_SECONDS, - } - encoded = base64.urlsafe_b64encode( - json.dumps(payload, separators=(",", ":")).encode("utf-8") - ).decode("ascii").rstrip("=") - signature = hmac.new( - secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256 - ).hexdigest() - return f"{encoded}.{signature}" - - -def _restore_portal_remembered_session() -> None: - if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): - return - token = st.query_params.get(PORTAL_REMEMBER_TOKEN_PARAM, "") - if not isinstance(token, str) or not token or len(token) > 2048: - return - secret = _portal_remember_secret() - expected_username = _portal_auth_value("POC4_LOGIN_USER") - try: - encoded, supplied_signature = token.split(".", 1) - expected_signature = hmac.new( - secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256 - ).hexdigest() - padded = encoded + "=" * (-len(encoded) % 4) - payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) - expires_at = int(payload["e"]) - username = str(payload["u"]) - except (binascii.Error, KeyError, TypeError, ValueError, UnicodeDecodeError): - return - if not secret or not hmac.compare_digest(supplied_signature, expected_signature): - return - if expires_at < int(datetime.now(timezone.utc).timestamp()): - return - if not hmac.compare_digest(username, expected_username): + if not authenticated: + st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None) + st.session_state.pop(PORTAL_AUTH_USER_KEY, None) return st.session_state[PORTAL_AUTHENTICATED_KEY] = True st.session_state[PORTAL_AUTH_USER_KEY] = username - st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False - - -def _clear_portal_remembered_session() -> None: - if PORTAL_REMEMBER_TOKEN_PARAM in st.query_params: - del st.query_params[PORTAL_REMEMBER_TOKEN_PARAM] def _render_portal_login(profile: AppProfile) -> None: with st.container(key="console_login_container"): render_login_brand(st, profile) - if not _portal_auth_configured(): - st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.") - return - with st.form("poc4_portal_login_form", clear_on_submit=True): - username = st.text_input( - "사용자 ID", - max_chars=80, - placeholder="사용자 ID를 입력하세요.", - ) - password = st.text_input( - "비밀번호", - type="password", - max_chars=200, - placeholder="비밀번호를 입력하세요.", - ) - remember_login = st.checkbox( - "로그인 유지 (7일)", - disabled=not bool(_portal_remember_secret()), - help="이 브라우저에서 7일 동안 로그인 상태를 유지합니다.", - ) - submitted = st.form_submit_button("로그인", use_container_width=True) - if submitted: - if _portal_credentials_are_valid(username, password): - st.session_state[PORTAL_AUTHENTICATED_KEY] = True - st.session_state[PORTAL_AUTH_USER_KEY] = username.strip() - st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False - if remember_login: - token = _portal_remember_token(username) - if token: - st.query_params[PORTAL_REMEMBER_TOKEN_PARAM] = token - else: - _clear_portal_remembered_session() - st.rerun() - st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True - if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False): - st.error("사용자 ID 또는 비밀번호를 확인해 주세요.") + st.error( + "인증 게이트웨이의 사용자 확인 정보가 없습니다. " + "공식 포털 주소로 다시 접속해 주세요." + ) st.markdown( f'

{html.escape(profile.login_footer)}

', unsafe_allow_html=True, ) -def _logout_portal() -> None: - _clear_portal_remembered_session() - st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None) - st.session_state.pop(PORTAL_AUTH_USER_KEY, None) - st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None) - st.rerun() - - def _render_app_header(profile: AppProfile) -> None: render_console_header(st, profile) -def _render_vpd_user_card(preset: VpdTokenPreset) -> None: +def _render_demo_user_card(preset: VpdTokenPreset) -> None: st.markdown( f"""
@@ -2055,10 +628,10 @@ def _render_vpd_user_card(preset: VpdTokenPreset) -> None: {html.escape(preset.user_id)} · {html.escape(preset.name)}
- {html.escape(preset.role)} · {html.escape(preset.channel)} + {html.escape(preset.role)} · {html.escape(preset.team or preset.channel)}
- 권한 범위: {html.escape(preset.scope)} + 테스트 문맥: {html.escape(preset.scope)}
""", @@ -2130,38 +703,61 @@ class AuditLogError(RuntimeError): def _audit_db_env_value(name: str, default: str = "") -> str: - return ( - os.environ.get(name) - or _dotenv_value(name, AUDIT_DB_ENV_FILE) - or default - ).strip() + names = [name] + if name.startswith("AI_WEB_AGENT_CONSOLE_"): + names.append(name.replace("AI_WEB_AGENT_CONSOLE_", "POC4_", 1)) + for candidate in names: + value = ( + os.environ.get(candidate) + or _dotenv_value(candidate, AUDIT_DB_ENV_FILE) + ).strip() + if value: + return value + return default.strip() @st.cache_resource(show_spinner=False) def _audit_db_pool() -> Any: - password = _audit_db_env_value("ORACLE_DB_PASSWORD") + password = _audit_db_env_value("AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD") if not password: raise AuditLogError("감사로그 DB 접속 설정을 확인해 주세요.") - wallet_dir = Path( - _audit_db_env_value( - "ORACLE_WALLET_DIR", - "/home/opc/wallet/kbaipoc", - ) - ).expanduser().resolve() - if not wallet_dir.is_dir(): - raise AuditLogError("감사로그 DB Wallet 경로를 확인해 주세요.") - try: - return oracledb.create_pool( - user=_audit_db_env_value("ORACLE_DB_USER", "ADMIN"), - password=password, - dsn=_audit_db_env_value("ORACLE_DSN", "kbaipoc_high"), + dsn = _audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_DSN", DEFAULT_AUDIT_DB_DSN + ) + wallet_password = _audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD" + ) + pool_options: dict[str, Any] = {} + if wallet_password: + wallet_dir = Path( + _audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR", + "/home/opc/apps/vpd-backoffice/wallet", + ) + ).expanduser().resolve() + if not wallet_dir.is_dir(): + raise AuditLogError("감사로그 DB Wallet 경로를 확인해 주세요.") + pool_options.update( config_dir=str(wallet_dir), wallet_location=str(wallet_dir), - wallet_password=_audit_db_env_value("ORACLE_WALLET_PASSWORD") or None, + wallet_password=wallet_password, + ) + elif not (dsn.lstrip().startswith("(") or dsn.lower().startswith("tcps://")): + raise AuditLogError( + "감사로그 DB DSN은 TLS 접속 기술자이거나 Wallet 암호와 함께 제공되어야 합니다." + ) + try: + return oracledb.create_pool( + user=_audit_db_env_value( + "AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER", "ADMIN" + ), + password=password, + dsn=dsn, min=1, max=2, increment=1, getmode=oracledb.POOL_GETMODE_WAIT, + **pool_options, ) except (oracledb.Error, OSError, ValueError): raise AuditLogError("감사로그 DB에 연결하지 못했습니다.") from None @@ -2192,111 +788,59 @@ def _audit_rows( @st.cache_data(ttl=60, show_spinner=False) -def _load_fga_inventory() -> dict[str, list[dict[str, Any]]]: - policies = _audit_rows( +def _load_hmm_audit_inventory() -> list[dict[str, Any]]: + return _audit_rows( """ - SELECT policy.object_name, - policy.policy_name, - policy.policy_text, - LISTAGG(policy_columns.policy_column, ',') WITHIN GROUP ( - ORDER BY policy_columns.policy_column - ) AS policy_column, - policy.enabled, - policy.sel, - policy.ins, - policy.upd, - policy.del - FROM dba_audit_policies policy - LEFT JOIN dba_audit_policy_columns policy_columns - ON policy_columns.object_schema = policy.object_schema - AND policy_columns.object_name = policy.object_name - AND policy_columns.policy_name = policy.policy_name - WHERE policy.object_schema = :schema - GROUP BY policy.object_name, - policy.policy_name, - policy.policy_text, - policy.enabled, - policy.sel, - policy.ins, - policy.upd, - policy.del - ORDER BY policy.object_name, policy.policy_name - """, - {"schema": AUDIT_SCHEMA}, - ) - catalog = _audit_rows( - """ - SELECT object_name, - column_name, - policy_name, - policy_expression, - enabled_yn, - description, - updated_at - FROM POC_2.KB_SECURITY_POLICY_CATALOG - WHERE control_type = 'DBMS_FGA' - ORDER BY object_name, policy_name, column_name + SELECT event_type, + COUNT(*) AS event_count, + TO_CHAR( + MAX(created_at) AT TIME ZONE 'Asia/Seoul', + 'YYYY-MM-DD HH24:MI:SS' + ) AS latest_event_time + FROM ADMIN.HMM_ACCESS_AUDIT + GROUP BY event_type + ORDER BY event_type """ ) - return {"policies": policies, "catalog": catalog} @st.cache_data(ttl=30, show_spinner=False) -def _load_fga_audit_events( +def _load_hmm_audit_events( days: int, row_limit: int, - policy_name: str, - object_name: str, + event_type: str, + status: str, ) -> list[dict[str, Any]]: return _audit_rows( """ SELECT * FROM ( - SELECT TO_CHAR( - audit_event.event_timestamp AT TIME ZONE 'Asia/Seoul', + SELECT audit_id, + TO_CHAR( + created_at AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD HH24:MI:SS' ) AS event_time, - audit_event.dbusername, - audit_event.client_identifier, - audit_event.userhost, - audit_event.object_schema, - audit_event.object_name, - audit_event.action_name, - audit_event.fga_policy_name, - policy_columns.audit_column, - audit_event.return_code, - DBMS_LOB.SUBSTR(audit_event.sql_text, 1000, 1) AS sql_text - FROM unified_audit_trail audit_event - LEFT JOIN ( - SELECT object_schema, - object_name, - policy_name, - LISTAGG(policy_column, ',') WITHIN GROUP ( - ORDER BY policy_column - ) AS audit_column - FROM dba_audit_policy_columns - WHERE object_schema = :schema - GROUP BY object_schema, object_name, policy_name - ) policy_columns - ON policy_columns.object_schema = audit_event.object_schema - AND policy_columns.object_name = audit_event.object_name - AND policy_columns.policy_name = audit_event.fga_policy_name - WHERE audit_event.object_schema = :schema - AND audit_event.fga_policy_name IS NOT NULL - AND audit_event.event_timestamp >= ( - SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY') - ) - AND (:policy_name IS NULL OR audit_event.fga_policy_name = :policy_name) - AND (:object_name IS NULL OR audit_event.object_name = :object_name) - ORDER BY audit_event.event_timestamp DESC + event_type, + key_id, + object_id, + status, + row_count, + error_code, + message + FROM ADMIN.HMM_ACCESS_AUDIT + WHERE created_at >= ( + SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY') + ) + AND (:event_type IS NULL OR event_type = :event_type) + AND (:status IS NULL OR status = :status) + ORDER BY created_at DESC, audit_id DESC ) WHERE ROWNUM <= :row_limit """, { - "schema": AUDIT_SCHEMA, "days": int(days), - "policy_name": policy_name or None, - "object_name": object_name or None, + "event_type": event_type or None, + "status": status or None, "row_limit": int(row_limit), }, ) @@ -3587,11 +2131,15 @@ def _enrich_answer_with_business_evidence( return f"{verified}\n\n{normalized}".strip() -def _runtime_env_value(name: object) -> str: - key = str(name or "").strip() - if not key: - return "" - return (os.environ.get(key) or _dotenv_value(key)).strip() +def _runtime_env_value(*names: object) -> str: + for name in names: + key = str(name or "").strip() + if not key: + continue + value = (os.environ.get(key) or _dotenv_value(key)).strip() + if value: + return value + return "" def _looks_like_http_url(value: str) -> bool: @@ -3625,19 +2173,24 @@ def load_vpd_token_presets( except FileNotFoundError: return () except (OSError, UnicodeError, ValueError): - raise PublicMcpError(f"VPD 토큰 preset 설정을 읽지 못했습니다: {path}") from None + raise PublicMcpError(f"데모 사용자 preset 설정을 읽지 못했습니다: {path}") from None raw_presets = payload.get("presets") if isinstance(payload, Mapping) else None if not isinstance(raw_presets, list): - raise PublicMcpError("VPD 토큰 preset 설정에 presets 배열이 필요합니다.") + raise PublicMcpError("데모 사용자 preset 설정에 presets 배열이 필요합니다.") presets: list[VpdTokenPreset] = [] seen: set[str] = set() for item in raw_presets: if not isinstance(item, Mapping) or item.get("enabled", True) is not True: continue - token = _normalized_bearer(item.get("token")) + token_env = str( + item.get("mcp_token_env") or item.get("token_env") or "" + ).strip() + token = _normalized_bearer( + _runtime_env_value(token_env) if token_env else item.get("token") + ) user_id = str(item.get("user_id") or "").strip() - if not token or not user_id or user_id in seen: + if not user_id or user_id in seen: continue presets.append( VpdTokenPreset( @@ -3645,9 +2198,10 @@ def load_vpd_token_presets( user_id=user_id, name=str(item.get("name") or "").strip(), role=str(item.get("role") or "").strip(), - channel=str(item.get("channel") or "").strip(), + channel=str(item.get("channel") or item.get("team") or "").strip(), scope=str(item.get("scope") or "").strip(), is_default=item.get("default") is True, + team=str(item.get("team") or "").strip(), ) ) seen.add(user_id) @@ -4100,7 +2654,7 @@ def _mcp_summary(mcp_result: Any) -> dict[str, Any]: return {"type": type(mcp_result).__name__} payload = _mcp_response_payload(mcp_result) items = _mcp_items(mcp_result) - summary: dict[str, Any] = {} + summary: dict[str, Any] = status_result_summary(mcp_result) for key in ("toolName", "profile", "ordsPath", "generatedSql"): value = mcp_result.get(key) if key in mcp_result else payload.get(key) if value: @@ -4147,6 +2701,8 @@ def _complex_reasoning_model_profile(default_model_profile: str) -> str: configured = ( os.environ.get(COMPLEX_REASONING_MODEL_PROFILE_ENV) or _dotenv_value(COMPLEX_REASONING_MODEL_PROFILE_ENV) + or os.environ.get(LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV) + or _dotenv_value(LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV) ).strip() return configured or DEFAULT_COMPLEX_REASONING_MODEL_PROFILE or default_model_profile @@ -4187,6 +2743,8 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str: def _mcp_has_actionable_result(mcp_result: Any) -> bool: if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result): return True + if has_actionable_text_result(mcp_result): + return True payload = _mcp_response_payload(mcp_result) results = payload.get("results") if isinstance(results, list) and results: @@ -4251,7 +2809,10 @@ def _mcp_answer_evidence( if not isinstance(mcp_result, Mapping): return mcp_result payload = _mcp_response_payload(mcp_result) - evidence: dict[str, Any] = {} + evidence: dict[str, Any] = status_result_evidence( + mcp_result, + max_chars=max(3500, min(7000, max_text * 15)), + ) for key in ( "toolName", "profile", @@ -4986,6 +3547,11 @@ def run_mcp_agent_loop( if route is None: route = next(iter(routes_by_key.values())) route_key = _route_key(route.server_id, route.tool.name) + tool_query = append_query_contract_guidance( + tool_query, + original_question=question, + tool_name=route.tool.name, + ) is_distinct_vector_query = ( _is_vector_route(route) and tool_query not in completed_vector_queries @@ -5010,6 +3576,11 @@ def run_mcp_agent_loop( route, observations, ) + tool_query = append_query_contract_guidance( + tool_query, + original_question=question, + tool_name=route.tool.name, + ) thought = ( f"{thought} / 중복 MCP route {repeated_route_key} 대신 " f"미호출 route {route_key}로 전환합니다." @@ -5264,6 +3835,16 @@ def prepare_tool_query_for_mcp( fallback = str(question or "").strip() if not fallback: return fallback + if server.server_id == "hmm_hr_mcp": + return _prepare_hmm_hr_tool_query( + question=fallback, + tool=tool, + model_profile_key=model_profile_key, + selected_user_id=selected_user_id, + selected_user_role=selected_user_role, + selected_user_team=selected_user_channel, + selected_user_scope=selected_user_scope, + ) normalized_question = " ".join(fallback.casefold().split()) individual_scope_terms = ( "내 담당이 아닌", @@ -5414,6 +3995,111 @@ def prepare_tool_query_for_mcp( return _append_query_guidance(rewritten or fallback, query_guidance) +def _hmm_demo_user_context( + question: str, + *, + user_id: str, + role: str, + team: str, + scope: str, +) -> str: + """Make a selected HR persona useful without claiming row-level enforcement.""" + + normalized = str(question or "").strip() + if not user_id: + return normalized + profile = " · ".join(item for item in (user_id, role, team) if item) + purpose = f" 테스트 목적: {scope}." if scope else "" + return ( + f"현재 HMM HR 데모 사용자: {profile}.{purpose} " + "질문의 ‘나’, ‘내’, ‘우리 팀’은 이 데모 사용자를 기준으로 해석하고, " + "실제 행 수준 권한이 적용됐다고 주장하지 마세요.\n" + f"질문: {normalized}" + ) + + +def _prepare_hmm_hr_tool_query( + *, + question: str, + tool: McpTool, + model_profile_key: str, + selected_user_id: str, + selected_user_role: str, + selected_user_team: str, + selected_user_scope: str, +) -> str: + """Prepare an HMM HR query without inheriting retired KB/VPD prompt rules.""" + + fallback = str(question or "").strip() + def with_contract(value: str) -> str: + return append_query_contract_guidance( + value, + original_question=fallback, + tool_name=tool.name, + ) + + if tool.name in {"resolve_hr_term", "search_hr_policy"}: + return with_contract(fallback) + contextual_question = _hmm_demo_user_context( + fallback, + user_id=selected_user_id.strip(), + role=selected_user_role.strip(), + team=selected_user_team.strip(), + scope=selected_user_scope.strip(), + ) + if tool.name == "search_carrier_performance": + return with_contract(contextual_question) + try: + profile = resolve_model_profile(model_profile_key) + client = build_oci_genai_completion_client( + profile.model_id, + profile.answer_model_region, + profile.answer_model_endpoint, + ) + text = client.complete( + system_prompt=( + "You prepare one Korean natural-language query for an HMM HR MCP tool. " + "Return only JSON matching the schema. Do not answer the user, write SQL, " + "or expose/request tokens. Preserve employee codes exactly. " + "The HMM HR data tool can query organization, employees, leave balances, " + "leave requests, attendance, and standardized HR terms. The policy tool searches " + "HR policy PDF abstracts and chunks. The carrier federation tool joins ADB " + "employee assignments with PostgreSQL carrier KPI data by CARRIER_CODE. " + "A selected demo user only resolves pronouns " + "such as 'my' or 'our team'; do not claim that it enforces database access control." + ), + user_prompt=json.dumps( + { + "question": contextual_question, + "selected_tool_name": tool.name, + "selected_tool_description": tool.description[:1200], + }, + ensure_ascii=False, + ), + response_schema={ + "type": "object", + "additionalProperties": False, + "required": ["tool_query"], + "properties": {"tool_query": {"type": "string"}}, + }, + max_tokens=500, + temperature=temperature_for_model_profile(profile), + ) + parsed = json.loads(text) + rewritten = _clean_agent_tool_query(parsed.get("tool_query"), fallback) + except Exception: + return with_contract(contextual_question) + return with_contract( + _hmm_demo_user_context( + rewritten or fallback, + user_id=selected_user_id.strip(), + role=selected_user_role.strip(), + team=selected_user_team.strip(), + scope=selected_user_scope.strip(), + ) + ) + + def synthesize_answer( *, question: str, @@ -5427,6 +4113,72 @@ def synthesize_answer( security_evidence: Mapping[str, Any] | None = None, business_evidence: Mapping[str, Any] | None = None, ) -> Mapping[str, Any]: + contract_tools = {tool.name} + contract_tools.update( + str(step.get("tool_name") or "") + for step in (agent_steps or []) + if isinstance(step, Mapping) + ) + active_contracts_by_id: dict[str, Mapping[str, Any]] = {} + for contract_tool in contract_tools: + for contract in matching_query_contracts(question, contract_tool): + contract_id = str(contract.get("id") or "") + active_contracts_by_id[contract_id] = contract + active_contracts = tuple(active_contracts_by_id.values()) + contract_evidence = { + "mcp_result": mcp_result, + "agent_steps": list(agent_steps or []), + } + contract_report = evidence_contract_report(active_contracts, contract_evidence) + + if contract_report and any( + not bool(report.get("satisfied")) for report in contract_report + ): + missing_fields = sorted( + { + str(field) + for report in contract_report + for field in report.get("missing_fields", []) + } + ) + failed_calculations = sorted( + { + str(check.get("field") or "") + for report in contract_report + for check in report.get("computed_field_checks", []) + if not bool(check.get("satisfied")) + } + ) + failed_temporal_checks = sorted( + { + str(check.get("check") or "") + for report in contract_report + for check in report.get("temporal_contract_checks", []) + if not bool(check.get("satisfied")) + } + ) + evidence_issues = [] + if missing_fields: + evidence_issues.append("필수 필드 누락: " + ", ".join(missing_fields)) + if failed_calculations: + evidence_issues.append( + "계산 결과 불일치: " + ", ".join(failed_calculations) + ) + if failed_temporal_checks: + evidence_issues.append( + "시간 기준 계약 불일치: " + ", ".join(failed_temporal_checks) + ) + return { + "answer": missing_evidence_message(active_contracts), + "basis": evidence_issues or ["질의 계약 검증 실패"], + "limitations": ( + "필수 원장 근거가 충족되지 않아 잔여일수나 승인 가능 여부를 " + "추정하지 않았습니다." + ), + "query_contracts": list(active_contracts), + "evidence_contract_report": contract_report, + } + def build_user_prompt(*, compact: bool) -> str: evidence_items = 10 if compact else 20 evidence_text = 260 if compact else 380 @@ -5435,6 +4187,8 @@ def synthesize_answer( prompt_payload = { "question": question, "required_answer_checks": _answer_requirements(question), + "query_contracts": list(active_contracts), + "evidence_contract_report": contract_report, "conversation": [] if compact else conversation[-4:], "selected_route": { "server_id": server.server_id, @@ -5536,6 +4290,8 @@ def synthesize_answer( "say that clearly. Treat items_count=0 or results_count=0 as an actual " "zero-row result, not as missing tool delivery. Never claim that an audit " "event exists unless its identifier or audit result is in the evidence. " + "For compatibility MCP tools, the result string contains the factual " + "DOC and EVIDENCE lines; read it as evidence rather than treating it as metadata. " "For cross-source questions, keep structured contract/product facts separate " "from vector clause evidence, then combine only matching identifiers. If one " "source is missing, list confirmed and unconfirmed points separately instead " @@ -5548,6 +4304,10 @@ def synthesize_answer( "FC_ID scoping for customer data, plus active catalog document/chunk evidence. " "It takes precedence when Select AI returns an ambiguous or incorrect zero-row " "result. Never expose customer rows outside the business evidence scope. " + "The query_contracts and evidence_contract_report are authoritative. Follow " + "their required fields, calculations, temporal semantics, missing-record " + "semantics, forbidden fallbacks, and answer restrictions. Keep data facts " + "and policy requirements separate. " "Show audit_log_id when it is provided. Follow every required_answer_checks item in the user " "payload. When the user requests a result list, include every " "row provided in the MCP evidence, up to 20 rows, and state the total " @@ -5612,6 +4372,8 @@ def synthesize_answer( if evidence_verified: enriched["limitations"] = "" enriched["basis"] = basis[:5] + enriched["query_contracts"] = list(active_contracts) + enriched["evidence_contract_report"] = contract_report return enriched except Exception as exc: last_error = { @@ -5671,6 +4433,11 @@ def fallback_answer_from_mcp( lines.append(f"- {title or '검색 결과'}{suffix}: {body}") else: lines.append(f"- {compact}") + elif text_result(mcp_result): + result = text_result(mcp_result) + lines.append("") + lines.append("MCP 반환 근거:") + lines.append(result[:6000] + ("..." if len(result) > 6000 else "")) elif agent_steps: lines.append(f"Agent는 MCP tool을 {len(agent_steps)}회 호출했습니다.") else: @@ -5988,280 +4755,6 @@ def _render_architecture_tab() -> None: ) -def _render_fga_audit_tab() -> None: - st.markdown( - '
' - '감사로그 ( 오라클 FGA )' - '
', - unsafe_allow_html=True, - ) - st.markdown( - '
' - 'Oracle FGA 정책 상태와 민감 컬럼 접근 이력을 시간순으로 확인합니다. ' - '조회 조건을 선택하면 정책·객체·사용자·SQL 원문을 함께 비교할 수 있습니다.' - '
', - unsafe_allow_html=True, - ) - try: - inventory = _load_fga_inventory() - except AuditLogError as exc: - st.error(str(exc)) - return - - policies = inventory["policies"] - catalog = inventory["catalog"] - policy_names = sorted( - { - str(item.get("policy_name") or "").strip() - for item in (*policies, *catalog) - if str(item.get("policy_name") or "").strip() - } - ) - object_names = sorted( - { - str(item.get("object_name") or "").strip() - for item in (*policies, *catalog) - if str(item.get("object_name") or "").strip() - } - ) - - st.markdown( - '
조회 조건
', - unsafe_allow_html=True, - ) - with st.container(key="poc4_fga_filters"): - filter_policy, filter_object = st.columns(2) - with filter_policy: - selected_policy = st.selectbox( - "FGA 정책", - options=("", *policy_names), - format_func=lambda value: "전체 정책" if not value else value, - key="poc4_fga_policy_filter", - ) - with filter_object: - selected_object = st.selectbox( - "감사 객체", - options=("", *object_names), - format_func=lambda value: "전체 객체" if not value else value, - key="poc4_fga_object_filter", - ) - filter_days, filter_limit, refresh_column = st.columns([1.5, 1, 0.8]) - with filter_days: - days = st.slider( - "조회 기간", - min_value=1, - max_value=90, - value=7, - format="%d일", - key="poc4_fga_days", - ) - with filter_limit: - row_limit = st.number_input( - "최대 건수", - min_value=10, - max_value=500, - value=100, - step=10, - key="poc4_fga_row_limit", - ) - with refresh_column: - st.markdown( - '
', - unsafe_allow_html=True, - ) - if st.button( - "새로고침", - icon=":material/refresh:", - width="stretch", - key="poc4_fga_refresh", - ): - _load_fga_inventory.clear() - _load_fga_audit_events.clear() - st.rerun() - - try: - events = _load_fga_audit_events( - int(days), - int(row_limit), - selected_policy, - selected_object, - ) - except AuditLogError as exc: - st.error(str(exc)) - return - - success_count = sum( - 1 for item in events if int(item.get("return_code") or 0) == 0 - ) - failure_count = len(events) - success_count - with st.container(key="poc4_fga_metrics"): - metric_policy, metric_event, metric_success, metric_failure = st.columns(4) - metric_policy.metric("등록 FGA 정책", len(policies)) - metric_event.metric("조회 이벤트", len(events)) - metric_success.metric("성공", success_count) - metric_failure.metric("실패", failure_count) - - if not policies: - if catalog: - st.warning( - "FGA 정책 카탈로그는 존재하지만 현재 DB에 활성 정책이 등록되어 있지 않습니다." - ) - else: - st.warning( - f"현재 {AUDIT_SCHEMA} 스키마에 등록된 DBMS_FGA 정책이 없습니다." - ) - - st.markdown( - '
정책 상태
' - f'
현재 활성 정책 {len(policies)}건 · ' - '감사 대상 컬럼과 적용 조건을 확인합니다.
', - unsafe_allow_html=True, - ) - with st.container(key="poc4_fga_policy_panel"): - with st.expander("FGA 정책 상태", expanded=True): - if policies: - st.dataframe( - [ - { - "객체": str(item.get("object_name") or ""), - "정책": str(item.get("policy_name") or ""), - "감사 컬럼": str( - item.get("policy_column") or "전체" - ), - "조건": str(item.get("policy_text") or "항상"), - "활성": str(item.get("enabled") or ""), - "SELECT": str(item.get("sel") or ""), - } - for item in policies - ], - column_config={ - "객체": st.column_config.TextColumn(width="medium"), - "정책": st.column_config.TextColumn(width="large"), - "감사 컬럼": st.column_config.TextColumn(width="large"), - "조건": st.column_config.TextColumn(width="large"), - "활성": st.column_config.TextColumn(width="small"), - "SELECT": st.column_config.TextColumn(width="small"), - }, - hide_index=True, - width="stretch", - height=min(360, 72 + 36 * len(policies)), - ) - elif catalog: - st.dataframe( - [ - { - "객체": str(item.get("object_name") or ""), - "정책": str(item.get("policy_name") or ""), - "대상 컬럼": str(item.get("column_name") or "전체"), - "카탈로그 상태": str(item.get("enabled_yn") or ""), - "설명": str(item.get("description") or ""), - } - for item in catalog - ], - hide_index=True, - width="stretch", - height=min(360, 72 + 36 * len(catalog)), - ) - else: - st.caption("등록된 FGA 정책 정보가 없습니다.") - - st.markdown( - '
감사 이벤트
' - '
최신 이벤트부터 표시합니다. ' - '성공 여부와 감사 컬럼, 실행 사용자를 먼저 확인하세요.
', - unsafe_allow_html=True, - ) - with st.container(key="poc4_fga_event_toolbar"): - show_sql = st.toggle( - "SQL 원문 표시", - value=True, - key="poc4_fga_show_sql", - ) - if not events: - st.info("선택한 조건에 해당하는 FGA 감사 이벤트가 없습니다.") - return - - display_rows: list[dict[str, Any]] = [] - for event in events: - return_code = int(event.get("return_code") or 0) - actor = str(event.get("client_identifier") or "").strip() - if not actor: - actor = str(event.get("dbusername") or "") - display_row: dict[str, Any] = { - "발생시각(KST)": str(event.get("event_time") or ""), - "정책": str(event.get("fga_policy_name") or ""), - "감사 컬럼": str(event.get("audit_column") or "전체"), - "사용자": actor, - "DB 사용자": str(event.get("dbusername") or ""), - "접속 호스트": str(event.get("userhost") or ""), - "객체": ( - f"{event.get('object_schema')}.{event.get('object_name')}" - ), - "작업": str(event.get("action_name") or ""), - "결과": "성공" if return_code == 0 else f"ORA-{return_code:05d}", - } - if show_sql: - display_row["SQL 원문"] = " ".join( - str(event.get("sql_text") or "").split() - ) - display_rows.append(display_row) - - def initial_column_width( - column_name: str, - minimum: int, - maximum: int, - ) -> int: - values = [column_name] - values.extend(str(row.get(column_name) or "") for row in display_rows) - text_units = max( - sum(2 if ord(character) > 127 else 1 for character in value) - for value in values - ) - return max(minimum, min(maximum, 36 + text_units * 8)) - - event_column_config: dict[str, Any] = { - "발생시각(KST)": st.column_config.TextColumn( - width=initial_column_width("발생시각(KST)", 180, 220) - ), - "정책": st.column_config.TextColumn( - width=initial_column_width("정책", 180, 320) - ), - "감사 컬럼": st.column_config.TextColumn( - width=initial_column_width("감사 컬럼", 150, 300) - ), - "사용자": st.column_config.TextColumn( - width=initial_column_width("사용자", 110, 180) - ), - "DB 사용자": st.column_config.TextColumn( - width=initial_column_width("DB 사용자", 120, 180) - ), - "접속 호스트": st.column_config.TextColumn( - width=initial_column_width("접속 호스트", 150, 240) - ), - "객체": st.column_config.TextColumn( - width=initial_column_width("객체", 180, 300) - ), - "작업": st.column_config.TextColumn( - width=initial_column_width("작업", 90, 140) - ), - "결과": st.column_config.TextColumn( - width=initial_column_width("결과", 90, 140) - ), - } - if show_sql: - event_column_config["SQL 원문"] = st.column_config.TextColumn( - width=initial_column_width("SQL 원문", 420, 720) - ) - with st.container(key="poc4_fga_event_panel"): - st.dataframe( - display_rows, - column_config=event_column_config, - hide_index=True, - width="stretch", - height=min(640, 104 + 38 * len(display_rows)), - ) - - def _render_vpd_operations_tab() -> None: st.markdown( f""" @@ -6723,6 +5216,8 @@ def _process_submitted_question( ) assistant_message: dict[str, Any] synthesis_error_details: dict[str, Any] = {} + query_contract_details: list[Any] = [] + evidence_contract_details: list[Any] = [] answer_model_profile = reasoning_model_profile answer_synthesis_fallback_model = "" try: @@ -6754,6 +5249,10 @@ def _process_submitted_question( "basis": synthesized.get("basis", []), "limitations": str(synthesized.get("limitations") or ""), } + query_contract_details = list(synthesized.get("query_contracts") or []) + evidence_contract_details = list( + synthesized.get("evidence_contract_report") or [] + ) except AnswerSynthesisError as exc: synthesis_error_details = getattr(exc, "diagnostics", {}) or {} fallback_profile = DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE @@ -6793,6 +5292,12 @@ def _process_submitted_question( "basis": synthesized.get("basis", []), "limitations": str(synthesized.get("limitations") or ""), } + query_contract_details = list( + synthesized.get("query_contracts") or [] + ) + evidence_contract_details = list( + synthesized.get("evidence_contract_report") or [] + ) except AnswerSynthesisError as fallback_exc: synthesis_error_details = { "primary": synthesis_error_details, @@ -6867,6 +5372,8 @@ def _process_submitted_question( "agent_stop_reason": agent_stop_reason, "security_evidence": security_evidence, "business_evidence": business_evidence, + "query_contracts": query_contract_details, + "evidence_contract_report": evidence_contract_details, "execution_events": execution_events, "discovered_routes": [ { @@ -6930,7 +5437,7 @@ def main() -> None: page_title=profile.page_title, page_icon=profile.page_icon, layout="wide" ) _apply_console_theme(profile) - _restore_portal_remembered_session() + _restore_portal_proxy_session() if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): _render_portal_login(profile) return @@ -7002,8 +5509,11 @@ def main() -> None: st.caption( f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}" ) - if st.button("로그아웃", use_container_width=True): - _logout_portal() + st.markdown( + '로그아웃', + unsafe_allow_html=True, + ) st.divider() st.markdown('
AI 사용자 설정
', unsafe_allow_html=True) selected_query_model_profile = st.selectbox( @@ -7037,7 +5547,7 @@ def main() -> None: current_vpd_user_id ].select_label st.markdown( - '
VPD 사용자
', + '
데모 사용자
', unsafe_allow_html=True, ) vpd_popover_key = "poc4_vpd_user_popover_open" @@ -7052,7 +5562,7 @@ def main() -> None: on_change="rerun", ): selected_vpd_user_id = st.radio( - "VPD 사용자 선택", + "데모 사용자 선택", options=token_preset_ids, key=vpd_user_key, format_func=lambda user_id: token_preset_by_id[ @@ -7068,10 +5578,13 @@ def main() -> None: ) else: selected_token_preset = None - if configured_mcp_bearer: - # The HMM MCP gateway uses its own server-side credential. VPD user - # tokens remain UI context only and must never be forwarded to it. - st.caption("MCP 인증: 서버 관리 토큰 적용") + selected_preset_bearer = ( + selected_token_preset.token if selected_token_preset is not None else "" + ) + if configured_mcp_bearer or selected_preset_bearer: + # The HMM MCP gateway credential is never rendered. Each demo-user + # preset refers to its runtime env key, allowing secure profile swaps. + st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용") manual_bearer_token = "" elif selected_token_preset is not None: manual_bearer_token = st.text_input( @@ -7080,7 +5593,7 @@ def main() -> None: type="default", disabled=True, key=f"poc4_bearer_token_{selected_token_preset.user_id}", - help="선택한 VPD 사용자의 Bearer Token입니다.", + help="선택한 데모 사용자의 MCP gateway token입니다.", ) else: manual_bearer_token = st.text_input( @@ -7088,13 +5601,9 @@ def main() -> None: type="default", key="poc4_manual_bearer_token", ) - bearer_token = configured_mcp_bearer or ( - selected_token_preset.token - if selected_token_preset is not None - else manual_bearer_token - ) + bearer_token = selected_preset_bearer or configured_mcp_bearer or manual_bearer_token if selected_token_preset is not None: - _render_vpd_user_card(selected_token_preset) + _render_demo_user_card(selected_token_preset) all_conversation_rows = list_conversations(limit=200) current_row = next( @@ -7360,7 +5869,12 @@ def main() -> None: ) with audit_tab: - _render_fga_audit_tab() + render_hmm_audit_tab( + st, + _load_hmm_audit_inventory, + _load_hmm_audit_events, + AuditLogError, + ) with operations_tab: _render_vpd_operations_tab() diff --git a/poc4_active_source_20260714/config/app_profile.json b/ai-web-agent-console/config/app_profile.json similarity index 100% rename from poc4_active_source_20260714/config/app_profile.json rename to ai-web-agent-console/config/app_profile.json diff --git a/poc4_active_source_20260714/config/hmm_demo_scenarios.json b/ai-web-agent-console/config/hmm_demo_scenarios.json similarity index 56% rename from poc4_active_source_20260714/config/hmm_demo_scenarios.json rename to ai-web-agent-console/config/hmm_demo_scenarios.json index f50a9b4..4f032db 100644 --- a/poc4_active_source_20260714/config/hmm_demo_scenarios.json +++ b/ai-web-agent-console/config/hmm_demo_scenarios.json @@ -36,6 +36,27 @@ "category": "근태 현황", "title": "휴가 현황 조회", "question": "이번 달 팀원별 휴가 사용 현황을 보여줘" + }, + { + "id": "FED-01", + "enabled": true, + "category": "선사 실적 Federation", + "title": "팀원별 담당 선사 최신 실적", + "question": "E1001 팀장의 팀원별 담당 선사와 해당 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘" + }, + { + "id": "FED-02", + "enabled": true, + "category": "선사 실적 Federation", + "title": "위험 선사와 담당자", + "question": "E1001 팀에서 최신 위험 등급이 RED인 선사를 담당하는 직원, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘" + }, + { + "id": "FED-03", + "enabled": true, + "category": "선사 실적 Federation", + "title": "담당 선사 월별 추이", + "question": "E1006 직원이 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘" } ] } diff --git a/ai-web-agent-console/config/hmm_hr_query_contracts.json b/ai-web-agent-console/config/hmm_hr_query_contracts.json new file mode 100644 index 0000000..1014a9f --- /dev/null +++ b/ai-web-agent-console/config/hmm_hr_query_contracts.json @@ -0,0 +1,84 @@ +{ + "version": 1, + "contracts": [ + { + "id": "leave_balance_eligibility", + "description": "연차·휴가 잔여와 신청 가능 여부를 원장 연도 및 구성값으로 검증한다.", + "applies_to_tools": [ + "search_hr_data" + ], + "intent_match": { + "subject_terms_any": [ + "연차", + "휴가" + ], + "action_terms_any": [ + "잔여", + "가능", + "승인", + "신청", + "사용할 수" + ] + }, + "data_contract": { + "source_object": "HMM_LEAVE_BALANCES", + "identity_fields": [ + "EMPLOYEE_CODE" + ], + "required_fields": [ + "LEAVE_YEAR", + "ANNUAL_ENTITLEMENT", + "CARRYOVER_DAYS", + "USED_DAYS", + "PENDING_DAYS", + "AVAILABLE_REMAINING_DAYS" + ], + "computed_fields": { + "AVAILABLE_REMAINING_DAYS": { + "expression": "ANNUAL_ENTITLEMENT + CARRYOVER_DAYS - USED_DAYS - PENDING_DAYS", + "authoritative_source": "DATABASE_VIRTUAL_COLUMN" + } + }, + "missing_record_semantics": "NO_BALANCE_DATA_FOR_YEAR", + "forbidden_missing_record_fallbacks": [ + "ZERO", + "NVL", + "COALESCE", + "OUTER_JOIN_DEFAULT", + "LITERAL_VALUE" + ] + }, + "temporal_contract": { + "current_balance_year": "LATEST_AVAILABLE_LEAVE_YEAR", + "requested_period_and_current_balance_are_separate": true, + "period_status_fields": [ + "REQUEST_PERIOD_LABEL", + "PERIOD_STATUS" + ], + "past_period_status": "HISTORICAL_REQUEST_PERIOD", + "past_period_decision": "DO_NOT_DECIDE_NEW_APPLICATION_FROM_CURRENT_BALANCE", + "past_period_decision_field": "APPLICATION_DECISION_STATUS", + "past_period_decision_value": "NOT_APPLICABLE_FOR_HISTORICAL_PERIOD", + "forbidden_past_period_decision_field_patterns": [ + "^CAN_", + "^APPROVAL_(ALLOWED|DENIED)$" + ], + "validate_stated_days_against": [ + "INCLUSIVE_CALENDAR_DAYS", + "MONDAY_TO_FRIDAY_DAYS" + ], + "day_count_mismatch_status": "DATE_COUNT_REQUIRES_VALIDATION" + }, + "answer_contract": { + "separate_data_facts_and_policy_requirements": true, + "deny_conclusions_without_required_fields": [ + "ZERO_REMAINING_DAYS", + "LEAVE_APPROVAL_ALLOWED", + "LEAVE_APPROVAL_DENIED" + ], + "missing_evidence_response": "STATE_INSUFFICIENT_EVIDENCE_WITHOUT_INVENTING_VALUES", + "missing_evidence_message": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다." + } + } + ] +} diff --git a/poc4_active_source_20260714/config/mcp_servers.json b/ai-web-agent-console/config/mcp_servers.json similarity index 65% rename from poc4_active_source_20260714/config/mcp_servers.json rename to ai-web-agent-console/config/mcp_servers.json index 83c305c..3da95f4 100644 --- a/poc4_active_source_20260714/config/mcp_servers.json +++ b/ai-web-agent-console/config/mcp_servers.json @@ -8,15 +8,16 @@ "transport": "http", "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", "auth_token_env": "HMM_MCP_BEARER_TOKEN", - "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", + "timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS", "default_tool": "search_hr_data", "router_model_profile": "gpt54_mini_oci", "tool_allowlist": [ "search_hr_data", "resolve_hr_term", - "search_hr_policy" + "search_hr_policy", + "search_carrier_performance" ], - "description": "HMM HR knowledge and data MCP server" + "description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server" } ] } diff --git a/poc4_active_source_20260714/config/poc3_model_profiles.json b/ai-web-agent-console/config/model_profiles.json similarity index 94% rename from poc4_active_source_20260714/config/poc3_model_profiles.json rename to ai-web-agent-console/config/model_profiles.json index 2883a08..f7c973f 100644 --- a/poc4_active_source_20260714/config/poc3_model_profiles.json +++ b/ai-web-agent-console/config/model_profiles.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "registry_name": "POC3_8512_8513_MODEL_PROFILES", + "registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES", "default_model_profile": "gpt55_oci", "source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f", "profiles": [ @@ -15,7 +15,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2", "verification_status": "VERIFIED_WITH_WARNINGS", - "default_for_poc3": true, + "default_for_console": true, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.", "display_order": 0 @@ -31,7 +31,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1", "verification_status": "PARTIAL_VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.", "display_order": 1 @@ -47,7 +47,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3", "verification_status": "VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", "display_order": 2 @@ -63,7 +63,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3", "verification_status": "VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", "display_order": 3 @@ -79,7 +79,7 @@ "poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2", "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3", "verification_status": "VERIFIED", - "default_for_poc3": false, + "default_for_console": false, "source_tag": "poc_2-gpt55-oci-partial", "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", "display_order": 4 diff --git a/ai-web-agent-console/config/vpd_token_presets.json b/ai-web-agent-console/config/vpd_token_presets.json new file mode 100644 index 0000000..ecd510d --- /dev/null +++ b/ai-web-agent-console/config/vpd_token_presets.json @@ -0,0 +1,52 @@ +{ + "version": 2, + "description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.", + "presets": [ + { + "enabled": true, + "default": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1001", + "name": "Kim Minseo", + "role": "HR Team Manager", + "team": "HMM HR Demo Team", + "scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1002", + "name": "Lee Jiwon", + "role": "HR Operations Specialist", + "team": "HMM HR Demo Team", + "scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1003", + "name": "Park Dohyun", + "role": "People Analytics Analyst", + "team": "HMM HR Demo Team", + "scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1005", + "name": "Han Seojun", + "role": "Recruiting Specialist", + "team": "HMM HR Demo Team", + "scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모" + }, + { + "enabled": true, + "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "user_id": "E1007", + "name": "Kang Minho", + "role": "HR Coordinator", + "team": "HMM HR Demo Team", + "scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모" + } + ] +} diff --git a/poc4_active_source_20260714/requirements-langgraph.txt b/ai-web-agent-console/requirements-langgraph.txt similarity index 100% rename from poc4_active_source_20260714/requirements-langgraph.txt rename to ai-web-agent-console/requirements-langgraph.txt diff --git a/poc4_active_source_20260714/requirements.txt b/ai-web-agent-console/requirements.txt similarity index 82% rename from poc4_active_source_20260714/requirements.txt rename to ai-web-agent-console/requirements.txt index e2cf71c..f9a6b82 100644 --- a/poc4_active_source_20260714/requirements.txt +++ b/ai-web-agent-console/requirements.txt @@ -1,5 +1,5 @@ openpyxl>=3.1,<4 +oci>=2.180,<3 oracledb>=2,<4 pandas>=2,<3 streamlit>=1.35,<2 - diff --git a/ai-web-agent-console/tests/test_auth_gateway.py b/ai-web-agent-console/tests/test_auth_gateway.py new file mode 100644 index 0000000..d4a46d8 --- /dev/null +++ b/ai-web-agent-console/tests/test_auth_gateway.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import hashlib +import http.client +import os +from pathlib import Path +import sys +import threading +import unittest +from urllib.parse import urlencode + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from ai_web_agent_console.auth_gateway import ( # noqa: E402 + AuthConfig, + LoginAttemptLimiter, + SESSION_COOKIE_NAME, + SessionTokenCodec, + build_handler, + clear_session_cookie_header, + password_matches, + session_cookie_header, +) + + +class AuthGatewayTest(unittest.TestCase): + def setUp(self) -> None: + self.secret = "s" * 48 + self.codec = SessionTokenCodec(self.secret) + + def test_session_token_round_trip_and_tamper_rejection(self) -> None: + token = self.codec.issue("demo-admin", 3600, now=1_000) + + session = self.codec.verify(token, "demo-admin", now=1_001) + + self.assertIsNotNone(session) + self.assertEqual("demo-admin", session.username) + self.assertEqual(4_600, session.expires_at) + self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001)) + self.assertIsNone(self.codec.verify(token, "other-user", now=1_001)) + + def test_expired_session_token_is_rejected(self) -> None: + token = self.codec.issue("demo-admin", 300, now=1_000) + + self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300)) + + def test_remember_cookie_has_required_security_attributes(self) -> None: + header = session_cookie_header("signed-value", 604_800) + + self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header) + self.assertIn("Path=/", header) + self.assertIn("Secure", header) + self.assertIn("HttpOnly", header) + self.assertIn("SameSite=Lax", header) + self.assertIn("Max-Age=604800", header) + self.assertNotIn("Domain=", header) + + def test_session_cookie_omits_persistent_max_age(self) -> None: + header = session_cookie_header("signed-value", None) + + self.assertNotIn("Max-Age", header) + self.assertIn("HttpOnly", header) + + def test_logout_cookie_expires_immediately(self) -> None: + header = clear_session_cookie_header() + + self.assertIn("Max-Age=0", header) + self.assertIn("Secure", header) + self.assertIn("HttpOnly", header) + + def test_pbkdf2_password_verification(self) -> None: + salt = bytes.fromhex("00112233445566778899aabbccddeeff") + expected = hashlib.pbkdf2_hmac( + "sha256", b"correct-password", salt, 200_000 + ).hex() + encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}" + + self.assertTrue(password_matches("correct-password", encoded)) + self.assertFalse(password_matches("wrong-password", encoded)) + + def test_rate_limiter_blocks_only_after_threshold(self) -> None: + limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10) + + limiter.record_failure("client", now=1) + self.assertFalse(limiter.blocked("client", now=2)) + limiter.record_failure("client", now=3) + self.assertTrue(limiter.blocked("client", now=4)) + self.assertFalse(limiter.blocked("client", now=20)) + + def test_environment_config_requires_new_cookie_secret(self) -> None: + previous = dict(os.environ) + try: + os.environ["POC4_LOGIN_USER"] = "demo-admin" + os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded" + os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None) + with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"): + AuthConfig.from_environment() + finally: + os.environ.clear() + os.environ.update(previous) + + def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None: + salt = bytes.fromhex("00112233445566778899aabbccddeeff") + expected = hashlib.pbkdf2_hmac( + "sha256", b"correct-password", salt, 200_000 + ).hex() + config = AuthConfig( + username="demo-admin", + password_pbkdf2=( + f"pbkdf2_sha256$200000${salt.hex()}${expected}" + ), + cookie_secret=self.secret, + port=8621, + ) + from http.server import ThreadingHTTPServer + + server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_address[1], timeout=3 + ) + try: + connection.request("GET", "/auth/login") + login_page = connection.getresponse() + body = login_page.read().decode("utf-8") + self.assertEqual(200, login_page.status) + csrf_header = next( + value + for name, value in login_page.getheaders() + if name.lower() == "set-cookie" + and value.startswith("__Host-HMM_LOGIN_CSRF=") + ) + csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0] + self.assertIn( + f'name="csrf" value="{csrf_value}"', + body, + ) + + payload = urlencode( + { + "csrf": csrf_value, + "username": "demo-admin", + "password": "correct-password", + "remember": "yes", + } + ) + connection.request( + "POST", + "/auth/login", + body=payload, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}", + }, + ) + logged_in = connection.getresponse() + logged_in.read() + self.assertEqual(303, logged_in.status) + self.assertEqual("/", logged_in.getheader("Location")) + self.assertNotRegex(logged_in.getheader("Location"), r"token|remember") + session_header = next( + value + for name, value in logged_in.getheaders() + if name.lower() == "set-cookie" + and value.startswith(f"{SESSION_COOKIE_NAME}=") + ) + session_value = session_header.split("=", 1)[1].split(";", 1)[0] + self.assertIn("Secure", session_header) + self.assertIn("HttpOnly", session_header) + self.assertIn("SameSite=Lax", session_header) + + connection.request( + "GET", + "/auth/check", + headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"}, + ) + check = connection.getresponse() + check.read() + self.assertEqual(204, check.status) + self.assertEqual("demo-admin", check.getheader("X-Auth-User")) + + connection.request( + "GET", + "/auth/check", + headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"}, + ) + tampered = connection.getresponse() + tampered.read() + self.assertEqual(401, tampered.status) + + connection.request( + "GET", + "/auth/logout", + headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"}, + ) + logout = connection.getresponse() + logout.read() + self.assertEqual(303, logout.status) + self.assertEqual("/auth/login", logout.getheader("Location")) + self.assertTrue( + any( + name.lower() == "set-cookie" and "Max-Age=0" in value + for name, value in logout.getheaders() + ) + ) + finally: + connection.close() + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/ai-web-agent-console/tests/test_query_contracts.py b/ai-web-agent-console/tests/test_query_contracts.py new file mode 100644 index 0000000..f25a707 --- /dev/null +++ b/ai-web-agent-console/tests/test_query_contracts.py @@ -0,0 +1,181 @@ +import unittest + +from ai_web_agent_console.query_contracts import ( + append_query_contract_guidance, + evidence_contract_report, + matching_query_contracts, +) + + +def test_leave_eligibility_question_selects_external_contract(): + contracts = matching_query_contracts( + "직원 E9999의 연차 잔여와 신청 가능 여부를 알려줘", + "search_hr_data", + ) + + assert [contract["id"] for contract in contracts] == [ + "leave_balance_eligibility" + ] + + +def test_unrelated_tool_does_not_receive_leave_contract(): + contracts = matching_query_contracts( + "직원 E9999의 연차 잔여를 알려줘", + "search_carrier_performance", + ) + + assert contracts == () + + +def test_incomplete_result_cannot_be_interpreted_as_zero_balance(): + contracts = matching_query_contracts( + "직원 E9999의 연차 사용 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT: {"items":[{"EMPLOYEE_CODE":"E9999",' + '"REMAINING_DAYS":0}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is False + assert "AVAILABLE_REMAINING_DAYS" in report[0]["missing_fields"] + assert "LEAVE_YEAR" in report[0]["missing_fields"] + + +def test_complete_database_row_satisfies_contract_without_fixture_values(): + contracts = matching_query_contracts( + "직원 E9999의 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":14}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is True + assert report[0]["validated_record"]["LEAVE_YEAR"] == 2031 + assert report[0]["validated_record"]["AVAILABLE_REMAINING_DAYS"] == 14 + + +def test_conflicting_computed_value_fails_contract(): + contracts = matching_query_contracts( + "직원 E9999의 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":0}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is False + assert report[0]["computed_field_checks"][0]["expected"] == "14" + assert report[0]["computed_field_checks"][0]["actual"] == "0" + + +def test_historical_period_rejects_yes_no_decision_and_missing_counts(): + contracts = matching_query_contracts( + "직원 E9999의 과거 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":14,' + '"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",' + '"CAN_APPLY":"YES","INCLUSIVE_CALENDAR_DAYS":11}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is False + failed = { + check["check"] + for check in report[0]["temporal_contract_checks"] + if not check["satisfied"] + } + assert failed == { + "past_period_decision", + "no_past_period_yes_no_decision", + "date_range_counts", + } + + +def test_historical_period_contract_accepts_non_decision_status(): + contracts = matching_query_contracts( + "직원 E9999의 과거 연차 신청 가능 여부를 알려줘", + "search_hr_data", + ) + evidence = { + "result": ( + 'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",' + '"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,' + '"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,' + '"AVAILABLE_REMAINING_DAYS":14,' + '"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",' + '"APPLICATION_DECISION_STATUS":' + '"NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",' + '"INCLUSIVE_CALENDAR_DAYS":11,' + '"MONDAY_TO_FRIDAY_DAYS":7}]}' + ) + } + + report = evidence_contract_report(contracts, evidence) + + assert report[0]["satisfied"] is True + + +def test_guidance_is_appended_from_json_contract(): + guided = append_query_contract_guidance( + "직원 E9999의 휴가 잔여를 조회해줘", + original_question="직원 E9999의 휴가 잔여를 조회해줘", + tool_name="search_hr_data", + ) + + assert '"id":"leave_balance_eligibility"' in guided + assert "AVAILABLE_REMAINING_DAYS" in guided + + +class QueryContractTest(unittest.TestCase): + test_leave_eligibility_question_selects_external_contract = staticmethod( + test_leave_eligibility_question_selects_external_contract + ) + test_unrelated_tool_does_not_receive_leave_contract = staticmethod( + test_unrelated_tool_does_not_receive_leave_contract + ) + test_incomplete_result_cannot_be_interpreted_as_zero_balance = staticmethod( + test_incomplete_result_cannot_be_interpreted_as_zero_balance + ) + test_complete_database_row_satisfies_contract_without_fixture_values = ( + staticmethod(test_complete_database_row_satisfies_contract_without_fixture_values) + ) + test_conflicting_computed_value_fails_contract = staticmethod( + test_conflicting_computed_value_fails_contract + ) + test_historical_period_rejects_yes_no_decision_and_missing_counts = ( + staticmethod(test_historical_period_rejects_yes_no_decision_and_missing_counts) + ) + test_historical_period_contract_accepts_non_decision_status = staticmethod( + test_historical_period_contract_accepts_non_decision_status + ) + test_guidance_is_appended_from_json_contract = staticmethod( + test_guidance_is_appended_from_json_contract + ) diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py new file mode 100644 index 0000000..7c0b3e6 --- /dev/null +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios +from ai_web_agent_console.profile import load_app_profile +from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments +from ai_web_agent_console.mcp_result import ( + has_actionable_text_result, + status_result_evidence, + status_result_summary, +) +from ai_web_agent_console.model_registry import load_model_registry + + +def _load_console_query_helpers(): + """Load the Streamlit entrypoint only when its optional runtime is installed.""" + + try: + from app import _prepare_hmm_hr_tool_query + except ModuleNotFoundError: + return None + return _prepare_hmm_hr_tool_query + + +class DemoScenarioConfigTest(unittest.TestCase): + def test_model_registry_uses_console_names(self) -> None: + registry = load_model_registry() + + self.assertEqual( + registry.registry_name, + "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES", + ) + self.assertTrue(registry.default_profile.default_for_console) + + def test_profile_environment_overrides_json_defaults(self) -> None: + path = Path(__file__).parents[1] / "config" / "app_profile.json" + with patch.dict( + "os.environ", + { + "AGENT_CONSOLE_SHORT_NAME": "HMM", + "AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트", + "AGENT_CONSOLE_PRIMARY_COLOR": "#003b70", + }, + clear=False, + ): + profile = load_app_profile(path) + + self.assertEqual(profile.short_name, "HMM") + self.assertEqual(profile.page_title, "HMM AI 업무 에이전트") + self.assertEqual(profile.primary_color, "#003b70") + + def test_profile_reads_dotenv_values(self) -> None: + path = Path(__file__).parents[1] / "config" / "app_profile.json" + with tempfile.TemporaryDirectory() as temp_dir: + env_file = Path(temp_dir) / ".env" + env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8") + profile = load_app_profile(path, env_file) + + self.assertEqual(profile.short_name, "HMM") + + def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None: + path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py" + source = path.read_text(encoding="utf-8") + + self.assertIn('[data-testid="stAppViewContainer"] li', source) + self.assertIn('[data-testid="stExpander"] summary', source) + self.assertIn('div[data-testid="stButton"] > button', source) + self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source) + self.assertIn('[data-testid="stTab"]', source) + self.assertIn('[role="tab"][aria-selected="true"]', source) + + def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None: + root = Path(__file__).parents[1] + entrypoint = (root / "app.py").read_text( + encoding="utf-8" + ) + renderer = (root / "ai_web_agent_console" / "audit.py").read_text( + encoding="utf-8" + ) + + self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint) + self.assertIn("_load_hmm_audit_inventory", entrypoint) + self.assertIn("(protocol=tcps)(port=1521)", entrypoint) + self.assertIn( + "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD", + entrypoint, + ) + self.assertIn("HMM 접근 관리", renderer) + self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint) + + def test_hmm_scenarios_are_enabled_and_unique(self) -> None: + path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" + scenarios = load_demo_scenarios(path) + + self.assertGreaterEqual(len(scenarios), 3) + self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios})) + self.assertTrue(all(item.question.strip() for item in scenarios)) + by_id = {item.scenario_id: item for item in scenarios} + self.assertEqual( + {"FED-01", "FED-02", "FED-03"}, + {"FED-01", "FED-02", "FED-03"} & set(by_id), + ) + self.assertTrue( + all("선사" in by_id[scenario_id].question for scenario_id in ( + "FED-01", "FED-02", "FED-03" + )) + ) + + def test_hmm_mcp_allows_carrier_federation_tool(self) -> None: + path = Path(__file__).parents[1] / "config" / "mcp_servers.json" + payload = json.loads(path.read_text(encoding="utf-8")) + server = next( + item for item in payload["servers"] if item["id"] == "hmm_hr_mcp" + ) + + self.assertIn( + "search_carrier_performance", + server["tool_allowlist"], + ) + + def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None: + path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json" + payload = json.loads(path.read_text(encoding="utf-8")) + presets = payload["presets"] + + self.assertEqual(payload["version"], 2) + self.assertEqual({item["user_id"] for item in presets}, { + "E1001", "E1002", "E1003", "E1005", "E1007" + }) + self.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" for item in presets)) + self.assertTrue(all("token" not in item for item in presets)) + + def test_duplicate_id_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "scenarios.json" + path.write_text( + json.dumps( + { + "scenarios": [ + {"id": "HR-01", "title": "one", "question": "q1"}, + {"id": "HR-01", "title": "two", "question": "q2"}, + ] + } + ), + encoding="utf-8", + ) + with self.assertRaises(ScenarioConfigError): + load_demo_scenarios(path) + + def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None: + tool = McpTool( + name="search_hr_data", + description="", + schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + read_only=True, + ) + + arguments = build_mcp_tool_arguments( + tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data" + ) + + self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"}) + + def test_term_tool_arguments_follow_discovered_term_schema(self) -> None: + tool = McpTool( + name="resolve_hr_term", + description="", + schema={ + "type": "object", + "properties": {"term": {"type": "string"}}, + "required": ["term"], + }, + read_only=True, + ) + + arguments = build_mcp_tool_arguments( + tool, "반차", 50, preferred_tool="search_hr_data" + ) + + self.assertEqual(arguments, {"term": "반차"}) + + def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None: + result = { + "status": "success", + "result": ( + "HR_POLICY_SEARCH_RESULT\n" + "EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준" + ), + } + + summary = status_result_summary(result, excerpt_chars=40) + evidence = status_result_evidence(result) + + self.assertEqual(summary["status"], "success") + self.assertGreater(summary["result_chars"], 40) + self.assertIn("KR_Leave_Policy.pdf", evidence["result"]) + self.assertTrue(has_actionable_text_result(result)) + + def test_no_data_text_is_not_actionable(self) -> None: + self.assertFalse( + has_actionable_text_result({"status": "success", "result": "No data found"}) + ) + + @unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional") + def test_policy_query_does_not_include_demo_user_context(self) -> None: + prepare = _load_console_query_helpers() + assert prepare is not None + tool = McpTool( + name="search_hr_policy", + description="Search policy documents", + schema={"properties": {"query": {"type": "string"}}}, + read_only=True, + ) + + query = prepare( + question="연차 휴가 이월 기준과 제한을 알려줘", + tool=tool, + model_profile_key="gpt54_mini_oci", + selected_user_id="E1001", + selected_user_role="HR Team Manager", + selected_user_team="HMM HR Demo Team", + selected_user_scope="팀원 6명 관리", + ) + + self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘") + + +if __name__ == "__main__": + unittest.main() diff --git a/sql/adb/00_cleanup.sql b/database/adb/00_cleanup.sql similarity index 100% rename from sql/adb/00_cleanup.sql rename to database/adb/00_cleanup.sql diff --git a/sql/adb/01_dblinks.sql b/database/adb/01_dblinks.sql similarity index 100% rename from sql/adb/01_dblinks.sql rename to database/adb/01_dblinks.sql diff --git a/sql/adb/02_perm_tables.sql b/database/adb/02_perm_tables.sql similarity index 100% rename from sql/adb/02_perm_tables.sql rename to database/adb/02_perm_tables.sql diff --git a/sql/adb/03_seed.sql b/database/adb/03_seed.sql similarity index 98% rename from sql/adb/03_seed.sql rename to database/adb/03_seed.sql index 9223f3e..8205191 100644 --- a/sql/adb/03_seed.sql +++ b/database/adb/03_seed.sql @@ -80,7 +80,7 @@ COMMIT; -- COMMIT; -- -- The policy function vpd_region_filter handles CSV → IN-list --- conversion automatically. See sql/adb/06_policy.sql. +-- conversion automatically. See database/adb/06_policy.sql. -- ------------------------------------------------------------ PROMPT === Seed complete === diff --git a/sql/adb/04_secure_ctx.sql b/database/adb/04_secure_ctx.sql similarity index 100% rename from sql/adb/04_secure_ctx.sql rename to database/adb/04_secure_ctx.sql diff --git a/sql/adb/05_views.sql b/database/adb/05_views.sql similarity index 100% rename from sql/adb/05_views.sql rename to database/adb/05_views.sql diff --git a/sql/adb/06_policy.sql b/database/adb/06_policy.sql similarity index 100% rename from sql/adb/06_policy.sql rename to database/adb/06_policy.sql diff --git a/sql/adb/06a_redaction.sql b/database/adb/06a_redaction.sql similarity index 100% rename from sql/adb/06a_redaction.sql rename to database/adb/06a_redaction.sql diff --git a/sql/adb/07_end_users.sql b/database/adb/07_end_users.sql similarity index 100% rename from sql/adb/07_end_users.sql rename to database/adb/07_end_users.sql diff --git a/sql/adb/08_tests_user_my.sql b/database/adb/08_tests_user_my.sql similarity index 100% rename from sql/adb/08_tests_user_my.sql rename to database/adb/08_tests_user_my.sql diff --git a/sql/adb/09_tests_user_pg.sql b/database/adb/09_tests_user_pg.sql similarity index 100% rename from sql/adb/09_tests_user_pg.sql rename to database/adb/09_tests_user_pg.sql diff --git a/sql/adb/10_tests_user_both.sql b/database/adb/10_tests_user_both.sql similarity index 100% rename from sql/adb/10_tests_user_both.sql rename to database/adb/10_tests_user_both.sql diff --git a/sql/adb/11_tests_user_none.sql b/database/adb/11_tests_user_none.sql similarity index 100% rename from sql/adb/11_tests_user_none.sql rename to database/adb/11_tests_user_none.sql diff --git a/sql/adb/12_tests_admin_audit.sql b/database/adb/12_tests_admin_audit.sql similarity index 100% rename from sql/adb/12_tests_admin_audit.sql rename to database/adb/12_tests_admin_audit.sql diff --git a/sql/adb/13_dds_variant.sql b/database/adb/13_dds_variant.sql similarity index 100% rename from sql/adb/13_dds_variant.sql rename to database/adb/13_dds_variant.sql diff --git a/sql/adb/14_tests_dds_user.sql b/database/adb/14_tests_dds_user.sql similarity index 100% rename from sql/adb/14_tests_dds_user.sql rename to database/adb/14_tests_dds_user.sql diff --git a/sql/adb/15_dds_cleanup.sql b/database/adb/15_dds_cleanup.sql similarity index 100% rename from sql/adb/15_dds_cleanup.sql rename to database/adb/15_dds_cleanup.sql diff --git a/sql/adb/16_agent_ords_security_local_cleanup.sql b/database/adb/16_agent_ords_security_local_cleanup.sql similarity index 100% rename from sql/adb/16_agent_ords_security_local_cleanup.sql rename to database/adb/16_agent_ords_security_local_cleanup.sql diff --git a/sql/adb/17_agent_ords_security_local_vpd_setup.sql b/database/adb/17_agent_ords_security_local_vpd_setup.sql similarity index 100% rename from sql/adb/17_agent_ords_security_local_vpd_setup.sql rename to database/adb/17_agent_ords_security_local_vpd_setup.sql diff --git a/sql/adb/18_agent_ords_security_local_vpd_test.sql b/database/adb/18_agent_ords_security_local_vpd_test.sql similarity index 100% rename from sql/adb/18_agent_ords_security_local_vpd_test.sql rename to database/adb/18_agent_ords_security_local_vpd_test.sql diff --git a/sql/adb/19_agent_ords_security_local_dds_setup.sql b/database/adb/19_agent_ords_security_local_dds_setup.sql similarity index 100% rename from sql/adb/19_agent_ords_security_local_dds_setup.sql rename to database/adb/19_agent_ords_security_local_dds_setup.sql diff --git a/sql/adb/20_agent_ords_security_local_dds_test.sql b/database/adb/20_agent_ords_security_local_dds_test.sql similarity index 100% rename from sql/adb/20_agent_ords_security_local_dds_test.sql rename to database/adb/20_agent_ords_security_local_dds_test.sql diff --git a/sql/adb/21_agent_ords_security_ords_enable_schema.sql b/database/adb/21_agent_ords_security_ords_enable_schema.sql similarity index 100% rename from sql/adb/21_agent_ords_security_ords_enable_schema.sql rename to database/adb/21_agent_ords_security_ords_enable_schema.sql diff --git a/sql/adb/22_agent_ords_security_ords_handler_setup.sql b/database/adb/22_agent_ords_security_ords_handler_setup.sql similarity index 100% rename from sql/adb/22_agent_ords_security_ords_handler_setup.sql rename to database/adb/22_agent_ords_security_ords_handler_setup.sql diff --git a/sql/adb/23_agent_ords_security_ords_handler_test.sql b/database/adb/23_agent_ords_security_ords_handler_test.sql similarity index 100% rename from sql/adb/23_agent_ords_security_ords_handler_test.sql rename to database/adb/23_agent_ords_security_ords_handler_test.sql diff --git a/sql/adb/24_agent_ords_security_inventory.sql b/database/adb/24_agent_ords_security_inventory.sql similarity index 100% rename from sql/adb/24_agent_ords_security_inventory.sql rename to database/adb/24_agent_ords_security_inventory.sql diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/database/adb/25_agent_ords_security_backoffice_support.sql similarity index 100% rename from sql/adb/25_agent_ords_security_backoffice_support.sql rename to database/adb/25_agent_ords_security_backoffice_support.sql diff --git a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql b/database/adb/26_agent_ords_security_dynamic_vpd_filter.sql similarity index 100% rename from sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql rename to database/adb/26_agent_ords_security_dynamic_vpd_filter.sql diff --git a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql b/database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql similarity index 99% rename from sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql rename to database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql index 8b6b1e2..649c7e5 100644 --- a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql +++ b/database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql @@ -3,7 +3,7 @@ -- Unit-style checks for cb_agent_doc_vpd_filter. -- -- Run after: --- @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +-- @database/adb/26_agent_ords_security_dynamic_vpd_filter.sql -- ============================================================ WHENEVER SQLERROR EXIT SQL.SQLCODE SET ECHO OFF diff --git a/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql b/database/adb/28_agent_ords_vector_tag_vpd_setup.sql similarity index 100% rename from sql/adb/28_agent_ords_vector_tag_vpd_setup.sql rename to database/adb/28_agent_ords_vector_tag_vpd_setup.sql diff --git a/sql/adb/29_agent_ords_vector_search_ords.sql b/database/adb/29_agent_ords_vector_search_ords.sql similarity index 100% rename from sql/adb/29_agent_ords_vector_search_ords.sql rename to database/adb/29_agent_ords_vector_search_ords.sql diff --git a/sql/adb/30_agent_ords_vector_tag_vpd_test.sql b/database/adb/30_agent_ords_vector_tag_vpd_test.sql similarity index 100% rename from sql/adb/30_agent_ords_vector_tag_vpd_test.sql rename to database/adb/30_agent_ords_vector_tag_vpd_test.sql diff --git a/sql/adb/31_agent_ords_backoffice_ux_metadata.sql b/database/adb/31_agent_ords_backoffice_ux_metadata.sql similarity index 100% rename from sql/adb/31_agent_ords_backoffice_ux_metadata.sql rename to database/adb/31_agent_ords_backoffice_ux_metadata.sql diff --git a/sql/adb/31_dds_standalone_demo_setup.sql b/database/adb/31_dds_standalone_demo_setup.sql similarity index 100% rename from sql/adb/31_dds_standalone_demo_setup.sql rename to database/adb/31_dds_standalone_demo_setup.sql diff --git a/sql/adb/32_dds_vector_tag_setup.sql b/database/adb/32_dds_vector_tag_setup.sql similarity index 100% rename from sql/adb/32_dds_vector_tag_setup.sql rename to database/adb/32_dds_vector_tag_setup.sql diff --git a/sql/adb/33_agent_ords_sql_trace_grant.sql b/database/adb/33_agent_ords_sql_trace_grant.sql similarity index 100% rename from sql/adb/33_agent_ords_sql_trace_grant.sql rename to database/adb/33_agent_ords_sql_trace_grant.sql diff --git a/sql/adb/34_agent_ords_execution_evidence_grant.sql b/database/adb/34_agent_ords_execution_evidence_grant.sql similarity index 100% rename from sql/adb/34_agent_ords_execution_evidence_grant.sql rename to database/adb/34_agent_ords_execution_evidence_grant.sql diff --git a/sql/adb/34_dds_token_data_grant_common_auth.sql b/database/adb/34_dds_token_data_grant_common_auth.sql similarity index 100% rename from sql/adb/34_dds_token_data_grant_common_auth.sql rename to database/adb/34_dds_token_data_grant_common_auth.sql diff --git a/sql/adb/35_dds_token_data_grant_cleanup.sql b/database/adb/35_dds_token_data_grant_cleanup.sql similarity index 100% rename from sql/adb/35_dds_token_data_grant_cleanup.sql rename to database/adb/35_dds_token_data_grant_cleanup.sql diff --git a/sql/adb/36_dds_sales_knowledge_scenario.sql b/database/adb/36_dds_sales_knowledge_scenario.sql similarity index 100% rename from sql/adb/36_dds_sales_knowledge_scenario.sql rename to database/adb/36_dds_sales_knowledge_scenario.sql diff --git a/sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql b/database/adb/37_dds_sales_knowledge_scenario_cleanup.sql similarity index 100% rename from sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql rename to database/adb/37_dds_sales_knowledge_scenario_cleanup.sql diff --git a/sql/adb/38_agent_ords_vector_all_permission_normalization.sql b/database/adb/38_agent_ords_vector_all_permission_normalization.sql similarity index 100% rename from sql/adb/38_agent_ords_vector_all_permission_normalization.sql rename to database/adb/38_agent_ords_vector_all_permission_normalization.sql diff --git a/sql/adb/39_agent_ords_default_user_display_name.sql b/database/adb/39_agent_ords_default_user_display_name.sql similarity index 100% rename from sql/adb/39_agent_ords_default_user_display_name.sql rename to database/adb/39_agent_ords_default_user_display_name.sql diff --git a/sql/adb/40_dds_protection_evidence.sql b/database/adb/40_dds_protection_evidence.sql similarity index 100% rename from sql/adb/40_dds_protection_evidence.sql rename to database/adb/40_dds_protection_evidence.sql diff --git a/sql/adb/41_dds_row_column_fixture.sql b/database/adb/41_dds_row_column_fixture.sql similarity index 100% rename from sql/adb/41_dds_row_column_fixture.sql rename to database/adb/41_dds_row_column_fixture.sql diff --git a/sql/adb/42_agent_ords_fga_execution_audit.sql b/database/adb/42_agent_ords_fga_execution_audit.sql similarity index 100% rename from sql/adb/42_agent_ords_fga_execution_audit.sql rename to database/adb/42_agent_ords_fga_execution_audit.sql diff --git a/sql/adb/42_dds_fga_execution_evidence.sql b/database/adb/42_dds_fga_execution_evidence.sql similarity index 100% rename from sql/adb/42_dds_fga_execution_evidence.sql rename to database/adb/42_dds_fga_execution_evidence.sql diff --git a/sql/adb/43_agent_ords_probe_id_handler_patch.sql b/database/adb/43_agent_ords_probe_id_handler_patch.sql similarity index 100% rename from sql/adb/43_agent_ords_probe_id_handler_patch.sql rename to database/adb/43_agent_ords_probe_id_handler_patch.sql diff --git a/sql/adb/44_dds_mcp_local_end_user_setup.sql b/database/adb/44_dds_mcp_local_end_user_setup.sql similarity index 100% rename from sql/adb/44_dds_mcp_local_end_user_setup.sql rename to database/adb/44_dds_mcp_local_end_user_setup.sql diff --git a/sql/adb/45_dds_mcp_local_end_user_test.sql b/database/adb/45_dds_mcp_local_end_user_test.sql similarity index 100% rename from sql/adb/45_dds_mcp_local_end_user_test.sql rename to database/adb/45_dds_mcp_local_end_user_test.sql diff --git a/sql/adb/46_kb_stakeholder_token_permission_example.sql b/database/adb/46_kb_stakeholder_token_permission_example.sql similarity index 100% rename from sql/adb/46_kb_stakeholder_token_permission_example.sql rename to database/adb/46_kb_stakeholder_token_permission_example.sql diff --git a/sql/adb/47_kb_stakeholder_permission_or_branches.sql b/database/adb/47_kb_stakeholder_permission_or_branches.sql similarity index 100% rename from sql/adb/47_kb_stakeholder_permission_or_branches.sql rename to database/adb/47_kb_stakeholder_permission_or_branches.sql diff --git a/sql/adb/48_kb_permission_condition_codes.sql b/database/adb/48_kb_permission_condition_codes.sql similarity index 100% rename from sql/adb/48_kb_permission_condition_codes.sql rename to database/adb/48_kb_permission_condition_codes.sql diff --git a/sql/adb/49_permission_static_sql_condition.sql b/database/adb/49_permission_static_sql_condition.sql similarity index 100% rename from sql/adb/49_permission_static_sql_condition.sql rename to database/adb/49_permission_static_sql_condition.sql diff --git a/sql/adb/51_kb_vpd_admin_token.sql b/database/adb/51_kb_vpd_admin_token.sql similarity index 100% rename from sql/adb/51_kb_vpd_admin_token.sql rename to database/adb/51_kb_vpd_admin_token.sql diff --git a/sql/adb/52_cleanup_to_kb_vpd_poc.sql b/database/adb/52_cleanup_to_kb_vpd_poc.sql similarity index 100% rename from sql/adb/52_cleanup_to_kb_vpd_poc.sql rename to database/adb/52_cleanup_to_kb_vpd_poc.sql diff --git a/sql/adb/53_drop_legacy_poc_objects.sql b/database/adb/53_drop_legacy_poc_objects.sql similarity index 100% rename from sql/adb/53_drop_legacy_poc_objects.sql rename to database/adb/53_drop_legacy_poc_objects.sql diff --git a/sql/adb/54_drop_legacy_dds_ords_examples.sql b/database/adb/54_drop_legacy_dds_ords_examples.sql similarity index 100% rename from sql/adb/54_drop_legacy_dds_ords_examples.sql rename to database/adb/54_drop_legacy_dds_ords_examples.sql diff --git a/sql/adb/55_reapply_kb_vpd_after_table_reload.sql b/database/adb/55_reapply_kb_vpd_after_table_reload.sql similarity index 100% rename from sql/adb/55_reapply_kb_vpd_after_table_reload.sql rename to database/adb/55_reapply_kb_vpd_after_table_reload.sql diff --git a/sql/adb/56_kb_select_ai_router_team.sql b/database/adb/56_kb_select_ai_router_team.sql similarity index 100% rename from sql/adb/56_kb_select_ai_router_team.sql rename to database/adb/56_kb_select_ai_router_team.sql diff --git a/sql/adb/57_kb_select_ai_router_team_test.sql b/database/adb/57_kb_select_ai_router_team_test.sql similarity index 100% rename from sql/adb/57_kb_select_ai_router_team_test.sql rename to database/adb/57_kb_select_ai_router_team_test.sql diff --git a/sql/adb/58_kb_select_ai_router_api.sql b/database/adb/58_kb_select_ai_router_api.sql similarity index 100% rename from sql/adb/58_kb_select_ai_router_api.sql rename to database/adb/58_kb_select_ai_router_api.sql diff --git a/sql/adb/59_kb_select_ai_router_ords.sql b/database/adb/59_kb_select_ai_router_ords.sql similarity index 100% rename from sql/adb/59_kb_select_ai_router_ords.sql rename to database/adb/59_kb_select_ai_router_ords.sql diff --git a/sql/adb/60_kb_vpd_admin_full_access_token.sql b/database/adb/60_kb_vpd_admin_full_access_token.sql similarity index 100% rename from sql/adb/60_kb_vpd_admin_full_access_token.sql rename to database/adb/60_kb_vpd_admin_full_access_token.sql diff --git a/sql/adb/62_kb_aso_masking_backoffice_metadata.sql b/database/adb/62_kb_aso_masking_backoffice_metadata.sql similarity index 100% rename from sql/adb/62_kb_aso_masking_backoffice_metadata.sql rename to database/adb/62_kb_aso_masking_backoffice_metadata.sql diff --git a/sql/adb/63_kb_aso_masking_rule_runtime.sql b/database/adb/63_kb_aso_masking_rule_runtime.sql similarity index 100% rename from sql/adb/63_kb_aso_masking_rule_runtime.sql rename to database/adb/63_kb_aso_masking_rule_runtime.sql diff --git a/sql/adb/64_kb_aso_masking_default_column_rules.sql b/database/adb/64_kb_aso_masking_default_column_rules.sql similarity index 100% rename from sql/adb/64_kb_aso_masking_default_column_rules.sql rename to database/adb/64_kb_aso_masking_default_column_rules.sql diff --git a/sql/adb/65_kb_select_ai_vpd_query_api.sql b/database/adb/65_kb_select_ai_vpd_query_api.sql similarity index 100% rename from sql/adb/65_kb_select_ai_vpd_query_api.sql rename to database/adb/65_kb_select_ai_vpd_query_api.sql diff --git a/sql/adb/66_kb_select_ai_vpd_query_ords.sql b/database/adb/66_kb_select_ai_vpd_query_ords.sql similarity index 100% rename from sql/adb/66_kb_select_ai_vpd_query_ords.sql rename to database/adb/66_kb_select_ai_vpd_query_ords.sql diff --git a/sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql b/database/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql similarity index 100% rename from sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql rename to database/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql diff --git a/sql/adb/70_sg_tool_user.sql b/database/adb/70_sg_tool_user.sql similarity index 100% rename from sql/adb/70_sg_tool_user.sql rename to database/adb/70_sg_tool_user.sql diff --git a/sql/adb/71_sg_identity_administration.sql b/database/adb/71_sg_identity_administration.sql similarity index 100% rename from sql/adb/71_sg_identity_administration.sql rename to database/adb/71_sg_identity_administration.sql diff --git a/database/adb/72_hmm_leave_team_vpd.sql b/database/adb/72_hmm_leave_team_vpd.sql new file mode 100644 index 0000000..0b082df --- /dev/null +++ b/database/adb/72_hmm_leave_team_vpd.sql @@ -0,0 +1,597 @@ +-- ============================================================ +-- 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 === diff --git a/database/adb/73_hmm_mcp_vpd_runtime.sql b/database/adb/73_hmm_mcp_vpd_runtime.sql new file mode 100644 index 0000000..1d8248a --- /dev/null +++ b/database/adb/73_hmm_mcp_vpd_runtime.sql @@ -0,0 +1,159 @@ +-- #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. diff --git a/database/adb/73_hmm_rds_pg_00_precheck.sql b/database/adb/73_hmm_rds_pg_00_precheck.sql new file mode 100644 index 0000000..dc207ac --- /dev/null +++ b/database/adb/73_hmm_rds_pg_00_precheck.sql @@ -0,0 +1,32 @@ +-- ============================================================ +-- 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. diff --git a/database/adb/74_hmm_rds_pg_01_credential.sql b/database/adb/74_hmm_rds_pg_01_credential.sql new file mode 100644 index 0000000..40e269b --- /dev/null +++ b/database/adb/74_hmm_rds_pg_01_credential.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- Step 1: create the PostgreSQL credential. +-- Run only this statement after replacing 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 => '' + ); +END; +/ + +-- Passwords are never returned by ALL_CREDENTIALS. +SELECT credential_name, username +FROM all_credentials +WHERE credential_name = 'HMM_RDS_PG_CRED'; diff --git a/database/adb/75_hmm_rds_pg_02_acl.sql b/database/adb/75_hmm_rds_pg_02_acl.sql new file mode 100644 index 0000000..4317dd1 --- /dev/null +++ b/database/adb/75_hmm_rds_pg_02_acl.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 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; diff --git a/database/adb/76_hmm_rds_pg_03_dblink.sql b/database/adb/76_hmm_rds_pg_03_dblink.sql new file mode 100644 index 0000000..04b665c --- /dev/null +++ b/database/adb/76_hmm_rds_pg_03_dblink.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- 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'; diff --git a/database/adb/77_hmm_rds_pg_04_remote_query.sql b/database/adb/77_hmm_rds_pg_04_remote_query.sql new file mode 100644 index 0000000..2f7a7d4 --- /dev/null +++ b/database/adb/77_hmm_rds_pg_04_remote_query.sql @@ -0,0 +1,34 @@ +-- ============================================================ +-- 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"; diff --git a/database/adb/78_hmm_rds_pg_05_views.sql b/database/adb/78_hmm_rds_pg_05_views.sql new file mode 100644 index 0000000..b967745 --- /dev/null +++ b/database/adb/78_hmm_rds_pg_05_views.sql @@ -0,0 +1,109 @@ +-- ============================================================ +-- 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; diff --git a/database/adb/79_hmm_rds_pg_06_verify.sql b/database/adb/79_hmm_rds_pg_06_verify.sql new file mode 100644 index 0000000..84f5960 --- /dev/null +++ b/database/adb/79_hmm_rds_pg_06_verify.sql @@ -0,0 +1,86 @@ +-- ============================================================ +-- 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; diff --git a/database/adb/80_hmm_rds_pg_99_cleanup.sql b/database/adb/80_hmm_rds_pg_99_cleanup.sql new file mode 100644 index 0000000..27e06d3 --- /dev/null +++ b/database/adb/80_hmm_rds_pg_99_cleanup.sql @@ -0,0 +1,63 @@ +-- ============================================================ +-- 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' +); diff --git a/database/adb/81_hmm_carrier_access_groups.sql b/database/adb/81_hmm_carrier_access_groups.sql new file mode 100644 index 0000000..03e12ff --- /dev/null +++ b/database/adb/81_hmm_carrier_access_groups.sql @@ -0,0 +1,209 @@ +-- ============================================================ +-- 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; diff --git a/database/adb/82_hmm_federation_relationship_comments.sql b/database/adb/82_hmm_federation_relationship_comments.sql new file mode 100644 index 0000000..a5418ca --- /dev/null +++ b/database/adb/82_hmm_federation_relationship_comments.sql @@ -0,0 +1,67 @@ +-- ============================================================ +-- 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; diff --git a/sql/source/mysql_setup.sql b/database/source/mysql_setup.sql similarity index 96% rename from sql/source/mysql_setup.sql rename to database/source/mysql_setup.sql index 82530e6..5eb8e78 100644 --- a/sql/source/mysql_setup.sql +++ b/database/source/mysql_setup.sql @@ -1,5 +1,5 @@ -- ============================================================ --- sql/source/mysql_setup.sql +-- database/source/mysql_setup.sql -- Run on the REMOTE MySQL instance (mysql CLI). -- -- Creates the customers table in the `ecommerce_poc` schema with @@ -12,7 +12,7 @@ -- -- 호출 예시 (run.sh source 가 자동 실행): -- mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" \ --- $MY_DB < sql/source/mysql_setup.sql +-- $MY_DB < database/source/mysql_setup.sql -- ============================================================ SELECT '=== Creating ecommerce_poc.customers (idempotent) ===' AS status; diff --git a/database/source/postgres_hmm_carrier_performance.sql b/database/source/postgres_hmm_carrier_performance.sql new file mode 100644 index 0000000..db766f6 --- /dev/null +++ b/database/source/postgres_hmm_carrier_performance.sql @@ -0,0 +1,300 @@ +-- ============================================================ +-- 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 diff --git a/database/source/postgres_hmm_carrier_performance_verify.sql b/database/source/postgres_hmm_carrier_performance_verify.sql new file mode 100644 index 0000000..8d5b4f8 --- /dev/null +++ b/database/source/postgres_hmm_carrier_performance_verify.sql @@ -0,0 +1,324 @@ +-- ============================================================ +-- 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 === diff --git a/sql/source/postgres_setup.sql b/database/source/postgres_setup.sql similarity index 96% rename from sql/source/postgres_setup.sql rename to database/source/postgres_setup.sql index 332b802..41cd911 100644 --- a/sql/source/postgres_setup.sql +++ b/database/source/postgres_setup.sql @@ -1,5 +1,5 @@ -- ============================================================ --- sql/source/postgres_setup.sql +-- database/source/postgres_setup.sql -- Run on the REMOTE PostgreSQL instance (psql). -- -- Creates the customers table in the `public` schema with sample @@ -10,7 +10,7 @@ -- -- 호출 예시 (run.sh source 가 자동 실행): -- PGPASSWORD=$PG_PASSWORD psql -h $PG_HOST -U $PG_USER -d $PG_DB \ --- -f sql/source/postgres_setup.sql +-- -f database/source/postgres_setup.sql -- ============================================================ \echo === Creating public.customers (idempotent) === diff --git a/dds-backoffice/README.md b/dds-backoffice/README.md index cd9d665..3c62f4c 100644 --- a/dds-backoffice/README.md +++ b/dds-backoffice/README.md @@ -56,10 +56,10 @@ ORDS Handler가 Bearer 값을 문자열로 바꾸는 것만으로 DDS Context가 먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다. ```bash -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 +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 bash scripts/setup-dds-token-data-grant.sh -sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/36_dds_sales_knowledge_scenario.sql +sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/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" @sql/adb/36_dds_sales_knowledge_scena `36_dds_sales_knowledge_scenario.sql`은 `agent_sales`와 `SALES_KNOWLEDGE_ROLE`을 만들고 `TECH_TAG=SALES`인 세일즈 청크(28004)를 추가합니다. 짧은 데모 토큰 `dds_sales_demo_token`을 사용하면 결과는 28004만 남아야 합니다. 운영에서는 이 토큰 대신 Bearer 관리 화면에서 발급한 임시 토큰을 사용합니다. -토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql`과 `sql/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다. +토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `database/adb/37_dds_sales_knowledge_scenario_cleanup.sql`과 `database/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다. 그 다음 DDS 인스턴스를 실행합니다. diff --git a/deploy/ai-web-agent-console/nginx/hmm-portal-auth.locations.conf b/deploy/ai-web-agent-console/nginx/hmm-portal-auth.locations.conf new file mode 100644 index 0000000..358bc6d --- /dev/null +++ b/deploy/ai-web-agent-console/nginx/hmm-portal-auth.locations.conf @@ -0,0 +1,41 @@ +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; +} diff --git a/deploy/ai-web-agent-console/systemd/ai-web-agent-console-audit.conf b/deploy/ai-web-agent-console/systemd/ai-web-agent-console-audit.conf new file mode 100644 index 0000000..b9ce2e6 --- /dev/null +++ b/deploy/ai-web-agent-console/systemd/ai-web-agent-console-audit.conf @@ -0,0 +1,2 @@ +[Service] +EnvironmentFile=/etc/hmm-poc4-audit.env diff --git a/deploy/ai-web-agent-console/systemd/hmm-portal-auth.service b/deploy/ai-web-agent-console/systemd/hmm-portal-auth.service new file mode 100644 index 0000000..bc9e67f --- /dev/null +++ b/deploy/ai-web-agent-console/systemd/hmm-portal-auth.service @@ -0,0 +1,29 @@ +[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 diff --git a/deploy/vpd-backoffice/backoffice.env.example b/deploy/vpd-backoffice/backoffice.env.example new file mode 100644 index 0000000..76848fb --- /dev/null +++ b/deploy/vpd-backoffice/backoffice.env.example @@ -0,0 +1,18 @@ +# 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"}]' + +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":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]' diff --git a/deploy/caddy/Caddyfile.template b/deploy/vpd-backoffice/caddy/Caddyfile.template similarity index 100% rename from deploy/caddy/Caddyfile.template rename to deploy/vpd-backoffice/caddy/Caddyfile.template diff --git a/deploy/caddy/install-caddy-config.sh b/deploy/vpd-backoffice/caddy/install-caddy-config.sh similarity index 100% rename from deploy/caddy/install-caddy-config.sh rename to deploy/vpd-backoffice/caddy/install-caddy-config.sh diff --git a/deploy/vpd-backoffice/vpd-backoffice-structured-data.conf b/deploy/vpd-backoffice/vpd-backoffice-structured-data.conf new file mode 100644 index 0000000..9d2655f --- /dev/null +++ b/deploy/vpd-backoffice/vpd-backoffice-structured-data.conf @@ -0,0 +1,2 @@ +[Service] +EnvironmentFile=/etc/vpd-backoffice.env diff --git a/docs/03-detailed-guide.md b/docs/03-detailed-guide.md index 5a0335f..806aecd 100644 --- a/docs/03-detailed-guide.md +++ b/docs/03-detailed-guide.md @@ -8,7 +8,7 @@ > 단위 매트릭스** 입니다 (README 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행 > 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로 > 사용합니다 — VPD 메커니즘 자체는 동일하므로 개념 이해에는 차이가 없습니다. -> region 필터를 실제로 켜려면 `sql/adb/03_seed.sql` 하단의 주석을 해제하세요. +> region 필터를 실제로 켜려면 `database/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 만 허용한 경우 -# (sql/adb/03_seed.sql 하단 UPDATE 주석 해제 후) +# (database/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 만 보임 diff --git a/docs/04-source-setup.md b/docs/04-source-setup.md index ffb49cb..d68e785 100644 --- a/docs/04-source-setup.md +++ b/docs/04-source-setup.md @@ -4,7 +4,7 @@ --- -## Postgres (`sql/source/postgres_setup.sql`) +## Postgres (`database/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 sql/source/postgres_setup.sql + -f database/source/postgres_setup.sql ``` --- -## MySQL (`sql/source/mysql_setup.sql`) +## MySQL (`database/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 \ - < sql/source/mysql_setup.sql + < database/source/mysql_setup.sql ``` --- -## ADB → 원격 DB Link (`sql/adb/01_dblinks.sql`) +## ADB → 원격 DB Link (`database/adb/01_dblinks.sql`) `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을 처리합니다. 별도 Database Gateway 설치 불필요. diff --git a/docs/05-dds-variant.md b/docs/05-dds-variant.md index f6a35bf..8c835fc 100644 --- a/docs/05-dds-variant.md +++ b/docs/05-dds-variant.md @@ -2,7 +2,7 @@ > 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로 > 그대로 동작합니다. 이 문서는 동일한 4-user 매트릭스를 Oracle AI Database -> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `sql/adb/13_dds_variant.sql` +> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `database/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" @sql/adb/13_dds_variant.sql +sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/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) -`sql/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회: +`database/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회: | 사용자 | `v_dds_customers_pg` | `v_dds_customers_my` | 판정 | |---|---|---|---| diff --git a/docs/06-agent-ords-vpd-dds-security-brief.md b/docs/06-agent-ords-vpd-dds-security-brief.md index d98cf64..6d0003a 100644 --- a/docs/06-agent-ords-vpd-dds-security-brief.md +++ b/docs/06-agent-ords-vpd-dds-security-brief.md @@ -1165,15 +1165,15 @@ ORDER BY rg.grantee, dg.object_name, dg.grant_name; | 파일 | 역할 | |---|---| | `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 | -| `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 정책과 권한을 중앙 조회 | +| `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 정책과 권한을 중앙 조회 | ### 7.1 VPD + Redaction 실행 검증 @@ -1634,7 +1634,7 @@ ORA-20101: Authorization header must be Bearer ### 7.4 중앙 권한 인벤토리 -VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `sql/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다. +VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `database/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다. VPD 정책 연결 확인: diff --git a/docs/design/424-spring-boot-vpd-backoffice/README.md b/docs/design/424-spring-boot-vpd-backoffice/README.md index 9440080..ee761f2 100644 --- a/docs/design/424-spring-boot-vpd-backoffice/README.md +++ b/docs/design/424-spring-boot-vpd-backoffice/README.md @@ -3,7 +3,7 @@ > **상태**: Draft > **작성**: [AI] Architect · **최종수정**: 2026-06-23 > **추적성** — Redmine: #424 · 관련 ADR: 없음 -> · 구현 파일: `pom.xml`, `src/main/**`, `src/test/**` · 테스트: `mvn test` +> · 구현 파일: `vpd-backoffice/pom.xml`, `vpd-backoffice/src/main/**`, `vpd-backoffice/src/test/**` · 테스트: `mvn test` ## 1. 목적 (Why) @@ -58,8 +58,8 @@ ### 모듈/파일 구조 ```text -pom.xml -src/main/java/com/cloudhandson/vpdbackoffice/ +vpd-backoffice/pom.xml +vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/ VpdBackofficeApplication.java config/ DataSourceConfig.java @@ -88,13 +88,13 @@ src/main/java/com/cloudhandson/vpdbackoffice/ TokenController.java ProbeController.java DashboardController.java -src/main/resources/ +vpd-backoffice/src/main/resources/ application.yml mapper/*.xml templates/**/*.html static/css/app.css static/js/app.js -src/test/java/com/cloudhandson/vpdbackoffice/ +vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/ ``` ### 데이터 흐름 @@ -251,7 +251,7 @@ MCP-style reasoning ## 12. 미해결 질문 (Open Questions) -- ORDS 검증 API는 기존 `sql/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다. +- ORDS 검증 API는 기존 `database/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다. - 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다. - 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다. - 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다. diff --git a/docs/design/456-vpd-predicate-rule-tests/README.md b/docs/design/456-vpd-predicate-rule-tests/README.md index 1f5475d..e27797f 100644 --- a/docs/design/456-vpd-predicate-rule-tests/README.md +++ b/docs/design/456-vpd-predicate-rule-tests/README.md @@ -3,7 +3,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-25 > **추적성** — Redmine: #456 · 관련 ADR: 없음 -> · 구현 파일: `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` +> · 구현 파일: `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` ## 1. 목적 (Why) @@ -78,8 +78,8 @@ I/O와 순수 로직 경계: ## 10. 테스트 계획 -- SQLcl: `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql` -- SQLcl: `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` +- SQLcl: `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql` +- SQLcl: `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` - Maven: `mvn test` - ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인. diff --git a/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md b/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md index 9db348a..4148a25 100644 --- a/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md +++ b/docs/design/456-vpd-predicate-rule-tests/fn-cb_agent_doc_vpd_filter.md @@ -1,7 +1,7 @@ # 함수 설계서: `cb_agent_doc_vpd_filter` (#456) > **부모 설계서**: ./README.md · **상태**: Approved -> **작성**: [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` +> **작성**: [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` ## 1. 시그니처 diff --git a/docs/design/462-predicate-injection-defense/README.md b/docs/design/462-predicate-injection-defense/README.md index 2ffe20e..97cb16d 100644 --- a/docs/design/462-predicate-injection-defense/README.md +++ b/docs/design/462-predicate-injection-defense/README.md @@ -3,7 +3,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-25 > **추적성** — Redmine: #462 · 관련 ADR: 없음 -> · 구현 파일: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test` +> · 구현 파일: `database/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. 테스트 계획 -- `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql` -- `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` +- `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql` +- `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` - `./run.sh backoffice-vpd-ords-test` - `mvn test` diff --git a/docs/design/547-vm-https-hardening/README.md b/docs/design/547-vm-https-hardening/README.md index 8a1fbc7..767b5f9 100644 --- a/docs/design/547-vm-https-hardening/README.md +++ b/docs/design/547-vm-https-hardening/README.md @@ -3,7 +3,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-28 > **추적성** — Redmine: #547 · 관련 ADR: 없음 -> · 구현 파일: `src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/caddy/Caddyfile.template` +> · 구현 파일: `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` > · 테스트: `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/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정. +- `deploy/vpd-backoffice/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정. - `scripts/configure-backoffice-https-vm.sh`: 입력 검증, 원격 설정 검증·백업·적용·확인. - `scripts/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크. - Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다. diff --git a/docs/design/559-independent-vpd-dds-demos/README.md b/docs/design/559-independent-vpd-dds-demos/README.md index 7cb30bb..2d5775b 100644 --- a/docs/design/559-independent-vpd-dds-demos/README.md +++ b/docs/design/559-independent-vpd-dds-demos/README.md @@ -2,7 +2,7 @@ > **상태**: VPD·DDS 병행 검증 완료, 토큰 기반 객체별 Data Grant 적용 완료 > **추적성**: Redmine #565, #566 · DDS 구현 커밋 `1e48864`, `a5e70bc`, `04cca64`, `8978be4` -> **기준 구현**: `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` +> **기준 구현**: `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` ## 목적 diff --git a/docs/design/567-ords-vpd-sql-trace/README.md b/docs/design/567-ords-vpd-sql-trace/README.md index af908b4..1ff3520 100644 --- a/docs/design/567-ords-vpd-sql-trace/README.md +++ b/docs/design/567-ords-vpd-sql-trace/README.md @@ -77,8 +77,8 @@ Authorization: Bearer ## 설치와 운영 절차 -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)을 순서대로 실행한다. +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)을 순서대로 실행한다. 3. 새 권한 결과 확인을 실행한다. 이전 요청에는 `CLIENT_ID`가 없으므로 소급해 매칭하지 않는다. 4. 백오피스 실행 계정이 Autonomous의 `UNIFIED_AUDIT_TRAIL`을 조회할 수 있어야 한다. 전통 FGA 환경은 `DBA_FGA_AUDIT_TRAIL`을 fallback으로 사용한다. 일반 계정이면 감사 조회 권한을 가진 전용 observer 계정을 사용한다. diff --git a/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md b/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md index 70f229e..a9e4007 100644 --- a/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md +++ b/docs/design/567-ords-vpd-sql-trace/fn-addLocalSqlTrace.md @@ -1,7 +1,7 @@ # 함수 설계서: `addLocalSqlTrace` (#567) > **부모 설계서**: ./README.md · **상태**: Approved -> **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test` +> **작성**: [AI] Architect · **구현**: `vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:addLocalSqlTrace` · **테스트**: `ProbeResultTest`, `mvn test` ## 1. 시그니처 diff --git a/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md b/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md index a175bfe..46936d4 100644 --- a/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md +++ b/docs/design/567-ords-vpd-sql-trace/fn-findVpdPredicate.md @@ -1,7 +1,7 @@ # 함수 설계서: `findVpdPredicate` (#567) > **부모 설계서**: ./README.md · **상태**: Approved -> **작성**: [AI] Architect · **구현**: `src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke +> **작성**: [AI] Architect · **구현**: `vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java:findVpdPredicate` · **테스트**: `mvn test`, 운영 smoke ## 1. 시그니처 diff --git a/docs/design/617-dds-mcp-end-user-context/README.md b/docs/design/617-dds-mcp-end-user-context/README.md index 135e862..7ddb6dc 100644 --- a/docs/design/617-dds-mcp-end-user-context/README.md +++ b/docs/design/617-dds-mcp-end-user-context/README.md @@ -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 게시: `sql/adb/44_dds_mcp_local_end_user_setup.sql` -> · 검증: `sql/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call` +> · 기존 PoC 게시: `database/adb/44_dds_mcp_local_end_user_setup.sql` +> · 검증: `database/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call` ## 1. 결정과 목적 diff --git a/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md b/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md index 2cb0ec7..a920a95 100644 --- a/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md +++ b/docs/design/654-poc4-mcp-ai-console-vpd-audit/README.md @@ -3,7 +3,9 @@ > **상태**: Implemented snapshot documented · follow-up contract pending > **작성**: [AI] Architect · **최종수정**: 2026-07-14 > **추적성** — Redmine: #654 · 관련 ADR: 없음 · 구현 커밋: `5bdd242`, `33399f9`, `1f6d974` -> · 스냅샷: `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` +> · 현재 소스: `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 설계서를 기준으로 한다. ## 1. 목적 (Why) @@ -17,17 +19,17 @@ PoC4 MCP AI Console이 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하 | 영역 | 현행 구현 | |---|---| -| 저장소 위치 | `poc4_active_source_20260714/` 단일 폴더에 격리 | -| 실행 진입점 | 직접 실행은 `streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622` | +| 저장소 위치 | `ai-web-agent-console/` 단일 폴더에 격리 | +| 실행 진입점 | 직접 실행은 `streamlit run ai-web-agent-console/app.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 화면 | `apps/poc4/mcp_discovery_ui.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 | -| MCP registry | `config/mcp_servers.json`. `kb_mcp`와 `kb_vector_mcp` 두 서버를 선언 | +| Streamlit 화면 | `ai-web-agent-console/app.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 | +| MCP registry | `ai-web-agent-console/config/mcp_servers.json` | | 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 | `config/vpd_token_presets.json`. 실제 토큰 원문은 포함하지 않고 placeholder만 둠 | +| 토큰 preset | `ai-web-agent-console/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은 `config/poc3_model_profiles.json`에서 로드 | +| OCI GenAI | `OCI_AUTH_TYPE=config_file`, `~/.oci/config`, `DEFAULT` profile 기반. model profile은 `ai-web-agent-console/config/model_profiles.json`에서 로드 | | 보안 원칙 | wallet, DB password, wallet password, 실제 VPD token, 대화 DB는 저장소에 포함하지 않음 | 현행 데이터 흐름은 다음과 같다. @@ -60,25 +62,24 @@ SQLite 대화 이력 저장 | 파일 | 책임 | |---|---| -| `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`를 참조 | +| `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 구조. 실제 토큰은 배포 환경에서 교체 | -런처 주의사항: 스냅샷에는 `mcp_discovery_ui.py` 직접 실행에 필요한 소스가 들어 있지만, 포함된 `start_8622...` script는 원본 서버의 공용 runtime wrapper에 의존한다. 이 저장소에서 독립 실행하려면 `SOURCE_README.md`의 직접 `streamlit run` 명령을 사용하거나, 별도 wrapper를 작성해야 한다. +현재 저장소에서는 `cd ai-web-agent-console && streamlit run app.py`를 독립 실행 진입점으로 사용한다. ## 1.3 현행 보안/비밀정보 경계 - `VpdTokenPreset.token`은 dataclass에서 `repr=False`이며, UI는 token을 정규화한 뒤 Authorization header에만 넣는다. - `_NoRedirectHandler`는 redirect 시 Authorization header가 다른 endpoint로 전달되는 것을 막는다. -- `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`만 읽는다. +- `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`만 읽는다. - 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 원문을 포함하지 않는다. @@ -97,7 +98,7 @@ SQLite 대화 이력 저장 ## 2. 범위 (Scope) - **포함**: - - `apps/poc4/mcp_discovery_ui.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비 + - `ai-web-agent-console/app.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 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완 @@ -300,8 +301,8 @@ Authorization: Bearer 배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다. - 원격 실제 위치: `/home/opc/poc_4/poc4_active_source_20260714.tar.gz` -- 사용자 제시 경로 `/home/opc/poc_4/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다. -- 저장소 위치: `poc4_active_source_20260714/` +- 사용자 제시 경로 `/home/opc/poc_4/ai-web-agent-console/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다. +- 저장소 위치: `ai-web-agent-console/` - 포함 파일: 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`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다. @@ -380,7 +381,7 @@ ENABLED_COUNT = 4 SELECT_POLICY_COUNT = 4 ``` -현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `sql/adb/42_agent_ords_fga_execution_audit.sql`은 `CB_VPD_EXEC_AUDIT_` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다. +현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `database/adb/42_agent_ords_fga_execution_audit.sql`은 `CB_VPD_EXEC_AUDIT_` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다. ### 15.4 감사 이벤트 저장 위치 @@ -437,7 +438,7 @@ DBA_FGA_AUDIT_TRAIL event_count_7d = 0 ### 15.6 소스 구현과 화면 연결 -PoC4 스냅샷의 `apps/poc4/mcp_discovery_ui.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다. +PoC4 스냅샷의 `ai-web-agent-console/app.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다. | 함수 | 조회 대상 | 역할 | |---|---|---| diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md new file mode 100644 index 0000000..3670007 --- /dev/null +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -0,0 +1,104 @@ +# 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`에 기록한다. diff --git a/docs/design/701-hmm-ai-agent-modularization/README.md b/docs/design/701-hmm-ai-agent-modularization/README.md index 4bafd67..9726cf9 100644 --- a/docs/design/701-hmm-ai-agent-modularization/README.md +++ b/docs/design/701-hmm-ai-agent-modularization/README.md @@ -2,7 +2,9 @@ ## 프로젝트 개요 -`poc4_active_source_20260714`는 레거시 스냅샷 경로이며, 정식 서비스명은 HMM AI 업무 에이전트다. HMM MCP를 통해 HR 데이터, 표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다. +정식 서비스명은 HMM AI 업무 에이전트이며 현재 소스 경계는 `ai-web-agent-console/`이다. +과거 `poc4_active_source_20260714` 스냅샷 경로는 #742에서 제거했다. HMM MCP를 통해 HR 데이터, +표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다. ## 목표 @@ -10,7 +12,7 @@ ## 현재 문제 -- `apps/poc4/mcp_discovery_ui.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다. +- `ai-web-agent-console/app.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다. - MCP 설정과 인증 토큰 규칙을 수정할 때 화면 코드까지 함께 읽어야 한다. - MCP 프로토콜 처리의 단위 검증 지점이 없다. @@ -18,14 +20,15 @@ | 모듈 | 책임 | Streamlit 의존 | | --- | --- | --- | -| `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 | 있음 | +| `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 | 있음 | ### 재사용 UI Shell과 제품 프로필 -공통 화면 shell은 `src/agent_console/`에서 제공하고, 특정 고객·PoC의 표현은 +공통 화면 shell은 `ai-web-agent-console/ai_web_agent_console/`에서 제공하고, 특정 고객·PoC의 표현은 `config/app_profile.json`에 둔다. 다른 프로젝트는 앱 코드를 복사·수정하지 않고 profile JSON을 교체할 수 있다. 실제 배포에서는 `AGENT_CONSOLE_NAME`, `AGENT_CONSOLE_HEADER_DESCRIPTION`, `AGENT_CONSOLE_PRIMARY_COLOR` 등 `AGENT_CONSOLE_*` 환경변수가 JSON 기본값보다 우선한다. @@ -48,7 +51,7 @@ 2. 변경은 Redmine 이슈에 설계·검증 결과와 Git commit SHA를 함께 기록한다. 3. Git commit message에는 Redmine 번호를 `refs #<번호>:` 형식으로 포함한다. 4. 환경별 값과 비밀값은 profile JSON에 넣지 않고 `.env` 또는 secret store에만 둔다. -5. UI CSS는 `src/agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다. +5. UI CSS는 `ai-web-agent-console/ai_web_agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다. 도메인 로직의 공개 오류는 `PublicMcpError`로 통일한다. UI는 이 오류를 사람이 이해할 수 있는 메시지로 표시하되 토큰과 HTTP 원문을 출력하지 않는다. diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index a4457ef..7c53b84 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -1,7 +1,7 @@ # HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702) -> 상태: Implementing -> 추적: Redmine #702 +> 상태: Implemented +> 추적: Redmine #702 / Git 브랜치: `hmm-backoffice` > 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN` ## 목적 @@ -20,6 +20,8 @@ | 역할 | `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`는 인사 조직 원장이므로 접근 그룹과 혼합하지 않는다. 이로써 한 직원이 여러 업무 접근 그룹에 속하면서도 인사 소속팀은 하나로 유지된다. @@ -35,6 +37,8 @@ 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 ``` ## 화면 및 호환성 @@ -43,17 +47,72 @@ HMM_ACCESS_AUDIT records every backoffice change `employment_status`의 `ACTIVE`/`INACTIVE`에 매핑한다. - `/groups`는 `HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록 논리 접근 그룹임을 화면에 명시한다. -- `/roles`는 HMM 접근 역할만 관리한다. 기존 VPD 객체 권한 규칙은 이번 범위에서 노출하지 않는다. +- `/roles`는 HMM 접근 역할만 관리한다. - `/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 행을 수정하거나 삭제하지 않는다. -- 배포 전 SQLcl로 테이블·FK·시드 역할을 확인하고, 배포 후 사용자·그룹·역할·토큰 화면을 HTTP - 수준에서 확인한다. +- 배포 전 전체 자동 테스트를 통과시키고, 배포 후 사용자·접근 그룹·역할·토큰뿐 아니라 보호 객체, + 권한, 마스킹, 접근 검증, 지식자료, MCP, 운영 메뉴를 로그인 세션으로 전수 확인한다. ## 롤백 애플리케이션은 이전 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 메타데이터 관리에서는 전체 컬럼을 유지한다. diff --git a/docs/design/707-hmm-leave-team-vpd/README.md b/docs/design/707-hmm-leave-team-vpd/README.md new file mode 100644 index 0000000..fc78203 --- /dev/null +++ b/docs/design/707-hmm-leave-team-vpd/README.md @@ -0,0 +1,116 @@ +# 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`를 비활성화하거나 제거한 뒤 사용자 역할·권한 메타데이터를 + 유지하는 방식으로 수행한다. 휴가 원장 데이터는 수정하거나 삭제하지 않는다. diff --git a/docs/design/709-hmm-portal-http-only-auth/README.md b/docs/design/709-hmm-portal-http-only-auth/README.md new file mode 100644 index 0000000..f59d470 --- /dev/null +++ b/docs/design/709-hmm-portal-http-only-auth/README.md @@ -0,0 +1,125 @@ +# 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=` 요청이 인증되지 않고 로그인 화면으로 이동한다. +- 로그인 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`에 기록한다. diff --git a/docs/design/712-hmm-backoffice-mcp-bearer/README.md b/docs/design/712-hmm-backoffice-mcp-bearer/README.md new file mode 100644 index 0000000..1cf02a8 --- /dev/null +++ b/docs/design/712-hmm-backoffice-mcp-bearer/README.md @@ -0,0 +1,78 @@ +# 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 + → 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`에 기록한다. diff --git a/docs/design/723-hmm-configurable-backoffice/README.md b/docs/design/723-hmm-configurable-backoffice/README.md new file mode 100644 index 0000000..be3d389 --- /dev/null +++ b/docs/design/723-hmm-configurable-backoffice/README.md @@ -0,0 +1,255 @@ +# 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까지 근거와 함께 완료되어 있다. diff --git a/docs/design/729-rds-carrier-performance-demo/README.md b/docs/design/729-rds-carrier-performance-demo/README.md new file mode 100644 index 0000000..c29c77c --- /dev/null +++ b/docs/design/729-rds-carrier-performance-demo/README.md @@ -0,0 +1,139 @@ +# 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 가상 원장이 검증된 뒤 별도 단계로 진행한다. diff --git a/docs/design/730-adb-rds-postgres-dblink-demo/README.md b/docs/design/730-adb-rds-postgres-dblink-demo/README.md new file mode 100644 index 0000000..48edb2a --- /dev/null +++ b/docs/design/730-adb-rds-postgres-dblink-demo/README.md @@ -0,0 +1,172 @@ +# 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 +구문의 ``만 실제 값으로 바꾸고, 비밀번호가 보이는 화면은 녹화에서 +가리거나 일시 정지한다. + +운영 전환 시에는 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에서 수행한다. diff --git a/docs/design/740-hmm-mcp-vpd-runtime/README.md b/docs/design/740-hmm-mcp-vpd-runtime/README.md new file mode 100644 index 0000000..c26c705 --- /dev/null +++ b/docs/design/740-hmm-mcp-vpd-runtime/README.md @@ -0,0 +1,123 @@ +# #740 HMM MCP VPD 실행 경계 복구 + +## 배경 + +HMM MCP의 `search_hr_data`는 현재 `ADMIN` JDBC 세션에서 +`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 실행한다. 토큰으로 +`HMM_ACCESS_CTX`를 설정해도 `ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 +`ADMIN.HMM_LEAVE_BALANCES`와 `ADMIN.HMM_LEAVE_REQUESTS`의 VPD 정책이 +실제 조회에 적용되지 않는다. + +컨텍스트 값이 올바른 것과 VPD가 적용되는 것은 별개의 조건이다. 보호 테이블을 +읽는 최종 SQL은 반드시 `EXEMPT ACCESS POLICY`가 없는 계정의 동일 DB 세션에서 +컨텍스트 설정과 함께 실행해야 한다. + +## 목표 + +- `ADMIN`은 Select AI `SHOWSQL` 생성만 담당한다. +- 비면제 런타임 스키마 `CB_ORDS`가 생성 SQL을 읽기 전용으로 실행한다. +- 실행 직전 같은 `CB_ORDS` 세션에서 + `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다. +- 실행 종료 시 성공·실패와 관계없이 `CLEAR_VPD_CONTEXT`를 호출하고 롤백한다. +- 런타임 계정이 `ADMIN`이거나 `EXEMPT ACCESS POLICY`를 가진 경우 fail-closed 한다. +- MCP `search_hr_data`만 새 경계로 전환하며 용어·정책 검색 도구는 기존 경로를 유지한다. + +## 구조 + +```text +MCP tools/call + └─ bearer token 해시 인증 + ├─ ADMIN / DBMS_CLOUD_AI.GENERATE(..., 'showsql') + │ └─ 허용된 HMM HR 객체만 포함한 SELECT/WITH 생성 + └─ CB_ORDS JDBC session + ├─ CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ...') + │ └─ ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(token) + ├─ runtime principal/컨텍스트 일치 검증 + ├─ SET TRANSACTION READ ONLY + ├─ 생성 SQL 실행 → HMM_LEAVE_SCOPE_POLICY 적용 + ├─ ROLLBACK + └─ CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT +``` + +## DB 설계 + +### 런타임 스키마 + +`CB_ORDS`에는 다음 최소 권한만 부여한다. + +- `CREATE SESSION` +- `ADMIN.CB_ORDS_HANDLER_PKG` 실행 +- Select AI 프로필의 승인 객체 6개에 대한 `SELECT` + - `HMM_ORG_TEAMS` + - `HMM_HR_EMPLOYEES` + - `HMM_LEAVE_BALANCES` + - `HMM_LEAVE_REQUESTS` + - `HMM_ATTENDANCE_DAILY` + - `HMM_HR_TERMS` + +`EXEMPT ACCESS POLICY`, `SELECT ANY TABLE`, 객체 생성 `ANY` 권한은 부여하지 않는다. +생성 SQL의 스키마 한정 여부에 영향을 받지 않도록 승인 객체에 한해 +`CB_ORDS` private synonym을 만든다. + +### 컨텍스트 패키지 + +`ADMIN.CB_ORDS_HANDLER_PKG`는 HTTP Authorization 값에서 Bearer 토큰을 +추출한 후 `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 호출한다. +토큰 원문은 테이블이나 로그에 저장하지 않는다. + +`CB_ORDS`에는 동일 이름의 private synonym만 제공하여 런타임 SQL에서는 +`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`로 호출한다. 컨텍스트 패키지 자체의 +직접 실행 권한은 런타임 계정에 노출하지 않는다. + +`CLEAR_VPD_CONTEXT`는 HMM 컨텍스트와 client identifier를 모두 정리한다. +유효하지 않은 인증 헤더는 컨텍스트를 먼저 지운 뒤 오류로 종료한다. + +## 애플리케이션 설계 + +`backoffice.select-ai` 설정을 두 연결로 분리한다. + +- 생성 연결: 기존 `db-url`, `db-username`, `db-password`, `profile` +- 실행 연결: `runtime-db-url`, `runtime-db-username`, `runtime-db-password` + +생성 연결은 기존 `BACKOFFICE_DB_*`를 기본값으로 사용할 수 있다. 실행 연결은 +명시적으로 설정해야 하며 생성 연결로 자동 폴백하지 않는다. + +실행 전 다음을 검증한다. + +1. `USER`가 `ADMIN`이 아니다. +2. `SESSION_PRIVS`에 `EXEMPT ACCESS POLICY`가 없다. +3. 패키지 호출 후 `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치한다. + +하나라도 실패하면 SQL을 실행하지 않는다. + +## 운영 설정 + +`search_hr_data`의 `executionType`을 `AGENT_TOOL`에서 `SELECT_AI`로 변경한다. +다른 두 도구는 그대로 유지한다. + +필수 환경값: + +```properties +BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE +BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= +BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS +BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD= +``` + +비밀번호는 Git·Redmine·로그에 기록하지 않고 서버 환경 파일에서만 관리한다. + +## 검증 기준 + +1. `CB_ORDS`에 `EXEMPT ACCESS POLICY`가 없음을 확인한다. +2. E1006 토큰으로 `HMM_LEAVE_BALANCES` 조회 결과의 직원은 E1006 한 명뿐이다. +3. E1006 토큰으로 `HMM_LEAVE_REQUESTS` 조회 결과에 E1002가 없다. +4. E1006이 E1002 휴가를 직접 요청하면 0건을 반환한다. +5. 관리자 E1001은 권한 규칙에 따라 자기 자신과 직속 팀원 범위를 조회한다. +6. 무효 토큰, 컨텍스트 불일치, 런타임 권한 오설정은 fail-closed 한다. + +## 롤백 + +- 애플리케이션 환경의 `search_hr_data`를 이전 `AGENT_TOOL` 정의로 되돌리고 + 이전 JAR를 재기동한다. +- `CB_ORDS` 스키마는 즉시 삭제하지 않고 계정을 잠가 조사 가능 상태로 보존한다. +- VPD 정책 자체와 기존 `HMM_ACCESS_CTX_PKG`는 변경하지 않는다. diff --git a/docs/design/742-application-oriented-repository-layout/README.md b/docs/design/742-application-oriented-repository-layout/README.md new file mode 100644 index 0000000..de90082 --- /dev/null +++ b/docs/design/742-application-oriented-repository-layout/README.md @@ -0,0 +1,112 @@ +# #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/`에서 새 소스 경로를 참조한다. +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.` 형식으로 통일한다. 테스트도 같은 공개 +패키지 경로만 사용한다. + +`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 스키마와 운영 데이터는 이 구조 변경에서 수정하지 않는다. diff --git a/docs/notes/2026-06-08-dds-qa-session.md b/docs/notes/2026-06-08-dds-qa-session.md index 34490d4..6ef6dc8 100644 --- a/docs/notes/2026-06-08-dds-qa-session.md +++ b/docs/notes/2026-06-08-dds-qa-session.md @@ -98,7 +98,7 @@ STEP 8 (선택) ALTER TABLE ... SET USE DATA GRANTS ONLY ENABLED -- MAC 모드 STEP 9 감사 (dba_data_grants 등) + 역순 정리 ``` -전체 스크립트는 세션 본문 참조. 이 POC 의 `sql/adb/13_dds_variant.sql` + `15_dds_cleanup.sql` 가 동일 패턴. +전체 스크립트는 세션 본문 참조. 이 POC 의 `database/adb/13_dds_variant.sql` + `15_dds_cleanup.sql` 가 동일 패턴. --- @@ -325,8 +325,8 @@ CREATE DATA GRANT admin.dg_scoped ### 이 POC 에서 보면 -- `sql/adb/06_policy.sql` + `permission` 테이블 = "앱/운영자가 행으로 권한 관리" → **VPD 답안** -- `sql/adb/13_dds_variant.sql` = "DBA 가 DDL 로 4 종 역할 정의" → **DDS 답안** +- `database/adb/06_policy.sql` + `permission` 테이블 = "앱/운영자가 행으로 권한 관리" → **VPD 답안** +- `database/adb/13_dds_variant.sql` = "DBA 가 DDL 로 4 종 역할 정의" → **DDS 답안** - 같은 결과를 두 다른 모델로 표현한 비교 데모 **권한 매핑 테이블 중심으로 가겠다면 VPD 그대로 두는 게 정답.** diff --git a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md b/docs/notes/hmm-brand-refresh.md similarity index 55% rename from poc4_active_source_20260714/HMM_BRAND_REFRESH.md rename to docs/notes/hmm-brand-refresh.md index 5e5aefd..c945ae5 100644 --- a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md +++ b/docs/notes/hmm-brand-refresh.md @@ -6,10 +6,23 @@ - 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다. - 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다. - 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다. -- 공통 화면 CSS와 로그인/헤더 renderer는 `src/agent_console/presentation.py`에서 관리한다. +- 공통 화면 CSS와 로그인/헤더 renderer는 + `ai-web-agent-console/ai_web_agent_console/presentation.py`에서 관리한다. HMM의 제품명·문구·색상은 `config/app_profile.json`에만 둔다. # HMM MCP runtime wiring The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`). Its gateway credential is supplied only at runtime as `HMM_MCP_BEARER_TOKEN` in the deployed `.env`; it is intentionally not committed and is not the selected VPD user's token. + +## HMM demo user presets + +`config/vpd_token_presets.json` is retained as a legacy deployment filename, but its content is +an HMM HR demo-user list. The Streamlit sidebar uses it to select a manager or team-member +persona and resolves pronouns such as “내” and “우리 팀” in the test question. Each preset uses +`mcp_token_env: HMM_MCP_BEARER_TOKEN`; the bearer value stays in `/opt/hmm-poc4/.env` and is +never rendered, downloaded, or committed. The selector is a test context, not a claim of +row-level authorization enforcement. + +The security-operation link in the Streamlit console points to +`https://hmm-backoffice.cloud-handson.com/`, the HMM access-management backoffice. diff --git a/docs/reference/ords-vpd-sql-trace.md b/docs/reference/ords-vpd-sql-trace.md index c78f478..f994562 100644 --- a/docs/reference/ords-vpd-sql-trace.md +++ b/docs/reference/ords-vpd-sql-trace.md @@ -12,10 +12,10 @@ 기존 Handler가 trace 필드를 반환하지 않으면 백오피스가 같은 DB 연결에서 `set_user_by_bearer` → `CB_AGENT_DOC_VPD_FILTER` → `clear_user` 순서로 기본 VPD predicate를 조회한다. 이 보조 조회가 실패해도 권한 결과 자체는 실패 처리하지 않는다. -기존 설치에 trace 권한을 추가하려면 ADMIN으로 `sql/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다. +기존 설치에 trace 권한을 추가하려면 ADMIN으로 `database/adb/33_agent_ords_sql_trace_grant.sql`을 실행한 뒤 CB_ORDS로 `22_agent_ords_security_ords_handler_setup.sql`을 재실행하거나 `/ords-handlers`에서 trace가 포함된 Handler source를 저장한다. 기본으로 표시되는 SQL은 해당 토큰 컨텍스트에서 VPD 정책 함수가 반환한 행 predicate를 기본 Handler SELECT에 결합한 확인용 SQL이다. Bearer 원문은 trace나 응답에 포함하지 않는다. 실행 DB가 `V$SQL`과 `DBMS_XPLAN` 조회를 허용하면 `/probe`는 최근 2분 내 같은 보호 객체의 SQL_ID, DB가 기록한 원문 SQL, cursor 누적 통계, Predicate Information도 함께 표시한다. `V$SQL` 원문은 VPD 주입 전 SQL이고, 실제 적용 조건은 `DBMS_XPLAN`의 Predicate Information에서 확인한다. 같은 객체에 동시 요청이 많은 경우에는 최근 cursor 매칭이므로 요청 ID 기반 상관 추적이 필요하다. -Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다. +Autonomous DB에서 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 ORDS 계정에 위임하지 못할 수 있다. 이 경우에는 SYS/DBA가 [34_agent_ords_execution_evidence_grant.sql](../../database/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해 진단 권한을 준비해야 한다. 권한을 얻지 못해도 접근 검증 결과와 기존 권한 조건 재현 SQL은 그대로 제공된다. diff --git a/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md b/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md index f44bbe7..62009bb 100644 --- a/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md +++ b/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md @@ -54,7 +54,7 @@ EXTERNAL_CLAUSE_NAME=개인용애니카다이렉트자동차보험 변경 스크립트: -- `sql/adb/65_kb_select_ai_vpd_query_api.sql` +- `database/adb/65_kb_select_ai_vpd_query_api.sql` ## RAG 근거 검색 확인 결과 diff --git a/docs/reports/2026-07-10-select-ai-profile-performance-report.md b/docs/reports/2026-07-10-select-ai-profile-performance-report.md index ee3beb3..3856e52 100644 --- a/docs/reports/2026-07-10-select-ai-profile-performance-report.md +++ b/docs/reports/2026-07-10-select-ai-profile-performance-report.md @@ -202,7 +202,7 @@ c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_P 대상 스크립트: ```text -sql/adb/65_kb_select_ai_vpd_query_api.sql +database/adb/65_kb_select_ai_vpd_query_api.sql ``` ### ORDS 응답 표시 @@ -216,7 +216,7 @@ profile=KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1 대상 스크립트: ```text -sql/adb/66_kb_select_ai_vpd_query_ords.sql +database/adb/66_kb_select_ai_vpd_query_ords.sql ``` ### MCP 응답 표시 @@ -226,8 +226,8 @@ MCP tool 응답 payload의 profile 표시와 화면 설명을 새 프로파일 대상 소스: ```text -src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java -src/main/resources/templates/mcp-sse.html +vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +vpd-backoffice/src/main/resources/templates/mcp-sse.html ``` ## 최종 속도 측정 diff --git a/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md b/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md new file mode 100644 index 0000000..b47fb2f --- /dev/null +++ b/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md @@ -0,0 +1,60 @@ +# HMM 백오피스 전 화면 브라우저 검증 + +- 검증일: 2026-07-23 KST +- 대상: `https://hmm-backoffice.cloud-handson.com` +- 인증: 운영 관리자 로그인 세션 +- 브라우저: Playwright Chromium, 1440×1000 +- 추적: Redmine #702 + +## 판정 기준 + +각 URL을 직접 열어 HTTP 200, 주 제목 렌더링, 오류 alert 부재를 검사했다. 본문에서 `ORA-`, +`데이터 처리 오류`, `Whitelabel Error Page`, `KBAIPOC`, `POC_2`, KB 보험원장 테이블명을 탐지하고, +브라우저 page error와 console error도 수집했다. + +## 메뉴 결과 + +| 영역 | URL | 결과 | +| --- | --- | --- | +| 대시보드 | `/` | PASS | +| 사용자 | `/users` | PASS | +| 접근 그룹 | `/groups` | PASS | +| 역할 | `/roles` | PASS | +| 행 접근 규칙 | `/permissions` | PASS | +| 컬럼 원문 표시 허용 | `/user-masking-rules` | PASS | +| 권한 현황 | `/effective-matrix` | PASS | +| 보호 상태 | `/vpd-policies` | PASS | +| 컬럼 마스킹 | `/masking-rules` | PASS | +| 토큰 발급 | `/tokens` | PASS | +| 접근 검증 | `/probe` | PASS | +| 조회 대상 | `/objects` | PASS | +| 정형 데이터 조회 | `/structured-data` | PASS | +| 조회 연동 | `/ords-handlers` | PASS | +| 지식자료 관리 | `/vector-knowledge` | PASS | +| 대화형 검색 | `/mcp-chatbot` | PASS | +| 검색 해석 | `/mcp-reasoning` | PASS | +| MCP 서비스 | `/mcp-sse` | PASS | +| 연동 점검 | `/mcp-client-demo` | PASS | +| 운영 현황 | `/operation-status` | PASS | +| 행 접근 필터 구조 | `/vpd-filter-runtime` | PASS | +| DB 메타데이터 | `/schema-metadata` | PASS | +| 보안 SQL 스크립트 | `/security-sql-scripts` | PASS | +| 고급 접근 조건 | `/vpd-filter-policies` | PASS | +| 시스템 설정 | `/settings` | PASS | +| DB 준비 상태 | `/settings/database` | PASS | + +총 26개 URL이 모두 통과했다. 오류 alert, KB/POC 잔재, page error, console error는 각각 0건이다. + +## HMM 정형 원장 결과 + +| JSON key | 테이블 | 렌더링 행 | 결과 | +| --- | --- | ---: | --- | +| `teams` | `ADMIN.HMM_ORG_TEAMS` | 1 | PASS | +| `employees` | `ADMIN.HMM_HR_EMPLOYEES` | 7 | PASS | +| `leave-balances` | `ADMIN.HMM_LEAVE_BALANCES` | 7 | PASS | +| `leave-requests` | `ADMIN.HMM_LEAVE_REQUESTS` | 8 | PASS | +| `attendance-daily` | `ADMIN.HMM_ATTENDANCE_DAILY` | 14 | PASS | +| `hr-terms` | `ADMIN.HMM_HR_TERMS` | 21 | PASS | + +`HMM_HR_TERMS.EMBEDDING`은 VECTOR이므로 JSON `previewColumns`에서 제외했다. 이 설정으로 원장 +미리보기는 21개 용어를 정상 표시하며, VECTOR 자체는 용어 검색 기능에서 계속 사용한다. diff --git a/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md b/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md new file mode 100644 index 0000000..9499f25 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md @@ -0,0 +1,60 @@ +# HMM 백오피스 MCP 사용자 Bearer 검증 보고서 + +- 일자: 2026-07-23 +- Redmine: #712 +- 브랜치: `hmm-backoffice` +- 서비스: `https://hmm-backoffice.cloud-handson.com/mcp` + +## 원인 + +백오피스에서 발급한 `vpd_live_*` 직원 토큰을 별도 호환 서버인 +`https://hmm-mcp.cloud-handson.com/mcp`에 보냈다. 이 서버의 Nginx는 운영 공용 +`HMM_MCP_BEARER_TOKEN` 한 개만 비교하므로, 직원 토큰에서 `Bearer ` 문자열을 제거해도 HTTP 401이 +정상이다. + +직원 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`다. 조사 시 이 경로는 MCP 도구를 +제공했지만 Authorization 값을 실제로 검증하지 않고 버리는 결함도 확인됐다. + +## 수정 + +- 모든 MCP method에서 `HMM_ACCESS_BEARER_TOKENS`의 SHA-256 해시, 만료, 회수, 직원 재직 상태 검증 +- 누락·무효·만료·회수 토큰은 HTTP 401 +- Tool 호출 connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` 실행 +- 같은 connection에서 `DBMS_CLOUD_AI_AGENT.RUN_TOOL` 실행 +- 성공·실패와 무관하게 `finally`에서 `HMM_ACCESS_CTX_PKG.CLEAR_USER` +- Agent Factory 표시 주소를 백오피스 MCP 주소로 수정 +- 공용 gateway와 직원 VPD endpoint의 주소·토큰 조합을 운영 문서에서 분리 + +토큰 원문은 Controller에서 `Bearer ` 접두어만 제거해 전달하며 로그·응답·DB에 기록하지 않는다. +DB에는 기존 SHA-256 해시와 식별용 prefix만 남는다. + +## 검증 + +| 항목 | 결과 | +|---|---| +| Maven 자동 테스트 | 107건 통과 | +| 같은 JDBC connection의 context 설정·Tool 실행·context 정리 | 단위 테스트 통과 | +| 운영 JAR SHA-256 | 로컬 검증 빌드와 일치 | +| 서비스 | `vpd-backoffice.service` active | +| 로그인 상태 | HTTP 200 | +| 무토큰 MCP | HTTP 401 | +| 활성 E1002 임시 토큰 initialize | HTTP 200 | +| 활성 E1002 임시 토큰 tools/list | HTTP 200 | +| Tool discovery | HMM Tool 3개 | +| `resolve_hr_term` Tool 호출 | HTTP 200 / MCP result 성공 | +| 회수 후 같은 토큰 | HTTP 401 | + +검증용 토큰은 백오피스의 정상 발급 흐름으로 만들고 원문을 출력하지 않았다. 검증 완료 시 즉시 +회수했으며, 이전 실패 과정에서 남은 같은 용도의 임시 토큰 한 건도 함께 회수했다. + +## Agent Factory 최종 입력 + +| 항목 | 값 | +|---|---| +| Server name | `hmm-backoffice-mcp` | +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | `Bearer Token` | +| Bearer token | 백오피스 발급 토큰 원문만 입력 | + +Token 입력란에 `Bearer ` 접두어를 직접 쓰지 않는다. Agent Factory가 HTTP +`Authorization: Bearer ` 헤더를 조립한다. OAuth URL과 client ID/secret은 사용하지 않는다. diff --git a/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md b/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md new file mode 100644 index 0000000..d9573b3 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md @@ -0,0 +1,61 @@ +# HMM 휴가 팀 계층 VPD 적용·검증 보고서 + +- 일자: 2026-07-23 +- Redmine: #707 +- 브랜치: `hmm-backoffice` +- 대상 DB: HMMAIPOC `ADMIN` +- 대상 서비스: `https://hmm-backoffice.cloud-handson.com` + +## 적용 결과 + +`database/adb/72_hmm_leave_team_vpd.sql`을 적용해 다음 구성을 활성화했다. + +| 구성 | 결과 | +|---|---| +| 신뢰 컨텍스트 | `HMM_ACCESS_CTX` | +| 토큰 검증 패키지 | `HMM_ACCESS_CTX_PKG` / `VALID` | +| 정책 함수 | `HMM_LEAVE_VPD_FILTER` / `VALID` | +| 휴가 잔여 정책 | `HMM_LEAVE_BALANCES.HMM_LEAVE_SCOPE_POLICY` / `ENABLE=YES` | +| 휴가 신청 정책 | `HMM_LEAVE_REQUESTS.HMM_LEAVE_SCOPE_POLICY` / `ENABLE=YES` | +| 팀장 역할 | E1001 → `HMM_HR_MANAGER` | +| 팀원 역할 | E1002~E1007 → `HMM_HR_VIEWER` | +| 저장 권한 | 두 객체 × `ALL`, `MANAGED_TEAM`, `SELF` = 6건 | + +## 실제 행 접근 검증 + +`ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 이를 결과 검증에 사용하지 않았다. 별도의 비면제 +임시 DB 사용자를 만들고, SHA-256 해시만 DB에 저장한 임시 E1001/E1002 토큰으로 조회한 뒤 +사용자와 토큰을 즉시 제거했다. + +| 검증 세션 | 휴가 잔여 | 휴가 신청 | 교차 사용자 조건 조회 | +|---|---:|---:|---:| +| 컨텍스트 없음 | 0 | 0 | - | +| E1001 팀장 | 7 | 8 | 직접 보고 팀원 포함 | +| E1002 팀원 | 1 | 1 | E1003 신청 0 | +| 무효 토큰 | 0 | - | 컨텍스트 초기화·차단 | + +결과: `VPD_VERIFY=PASS` + +## 애플리케이션 검증 + +- Maven: 105 tests, 0 failures, 0 errors +- 배포 JAR SHA-256: + `1b10582e72e4639902ea8f65156af1067941af3ef50cde1d9f767c88f048fb85` +- systemd: `vpd-backoffice.service` active +- 공개 로그인: HTTP 200 +- 실제 브라우저 전수 검사: + - 관리 화면 26개: 실패 0 + - HMM 정형 원장 6개: 실패 0 + - `ORA-*`, 데이터 처리 오류, KB/POC 잔존 문구: 0 + - page error: 0 + - console error: 0 + +특히 `/roles`, `/permissions`, `/vpd-policies`, `/vpd-filter-runtime`에서 HMM 역할 3개, +휴가 원장 권한 6개, 정책 2개와 `HMM_LEAVE_VPD_FILTER`가 표시되는 것을 화면으로 확인했다. + +## 보안 정리 + +- DB 비밀번호, 토큰 원문, 해시는 Git·Redmine·보고서에 기록하지 않았다. +- 임시 비면제 DB 사용자는 검증 종료 시 `DROP USER ... CASCADE`로 제거했다. +- 임시 토큰 2건은 검증 종료 시 삭제했다. +- HR 관리자 역할은 전체 접근 규칙만 준비하고 데모 사용자에게 자동 부여하지 않았다. diff --git a/docs/reports/2026-07-23-hmm-portal-cookie-auth-verification.md b/docs/reports/2026-07-23-hmm-portal-cookie-auth-verification.md new file mode 100644 index 0000000..0bb6342 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-portal-cookie-auth-verification.md @@ -0,0 +1,80 @@ +# HMM 포털 HttpOnly 쿠키 인증 적용·검증 보고서 + +- 일자: 2026-07-23 +- Redmine: #709 +- 브랜치: `hmm-backoffice` +- 서비스: `https://hmm.cloud-handson.com` + +## 수정 결과 + +Streamlit의 `poc4_remember` query-token 생성·복원 코드를 삭제했다. Nginx가 모든 포털 +HTTP·WebSocket 요청에 `auth_request`를 수행하고, localhost 인증 서비스가 검증한 사용자와 +만료 시각만 Streamlit에 전달한다. + +| 구성 | 결과 | +|---|---| +| 인증 서비스 | `hmm-portal-auth.service` / active | +| 인증 서비스 bind | `127.0.0.1:8621` | +| 포털 | `poc4-streamlit.service` / active | +| 공개 경계 | Nginx `auth_request` | +| session cookie | `__Host-HMM_PORTAL_SESSION` | +| cookie 속성 | `Secure; HttpOnly; SameSite=Lax; Path=/` | +| 로그인 CSRF | 10분 일회용 double-submit cookie | +| URL token | 제거 | +| 이전 서명키 | 운영 환경과 환경 백업에서 제거 | + +## 자동 테스트 + +```text +python3 -m unittest discover -s tests -p 'test_*.py' -v +Ran 21 tests +OK (skipped=1) +``` + +검증 항목: + +- PBKDF2 비밀번호 비교 +- session token 발급·검증·만료·조작 거부 +- persistent/session/logout cookie 속성 +- login rate limit +- 실제 HTTP login → auth check → tamper reject → logout 흐름 +- login 성공 redirect가 `/`이고 token·remember query가 없는지 확인 + +## 운영 HTTP 검증 + +| 요청 | 결과 | +|---|---| +| 쿠키 없이 `/` | 302 → `/auth/login` | +| `/?poc4_remember=retired-token` | 302 → `/auth/login`, query 전달 안 됨 | +| `/auth/login` | 200, `Cache-Control: no-store` | +| 조작 session cookie | 401 | +| 유효 session cookie | Streamlit 200 | +| `/auth/logout` | session cookie `Max-Age=0`, 로그인 화면 이동 | + +로그인 페이지 응답에는 `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy: +no-referrer`, 제한된 CSP가 포함된다. + +## 실제 브라우저 검증 + +Playwright에서 다음을 확인했다. + +- 미인증 query-token URL은 `/auth/login`으로 이동하고 최종 URL에서 query가 사라짐 +- 로그인 화면의 사용자 ID, 비밀번호, 로그인 유지 UI 표시 +- 인증 후 최종 URL은 `/`, query 없음 +- `__Host-HMM_PORTAL_SESSION`: HttpOnly=true, Secure=true, SameSite=Lax, Path=/ +- `document.cookie`에 portal session cookie가 없음 +- 아키텍처·시나리오·감사로그·보안관리 탭 필수 내용 표시 +- `https://hmm-mcp.cloud-handson.com/mcp` 설정 표시 +- 로그아웃 후 session cookie 없음 +- page error 0, console error 0 + +캡처와 기계 판독 보고서는 운영 검증 작업 디렉터리 +`/private/tmp/hmm-cookie-auth-audit/`에 생성했다. + +## 보안 정리 + +- 노출된 과거 query-token은 새 인증 경로에서 사용되지 않으며 기존 HMAC 서명키도 회전했다. +- 회전 전 환경 백업 두 개에서는 이전·중간 서명키 줄을 제거했다. +- 현재 secret은 `/opt/hmm-poc4/.env`에만 있고 파일 권한은 `opc:opc 0600`이다. +- 토큰, 비밀번호, cookie 값은 Git·Redmine·보고서·서비스 로그에 기록하지 않았다. +- Nginx access log에는 앞으로 인증 token이 URL로 들어오지 않는다. diff --git a/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md b/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md new file mode 100644 index 0000000..11d6312 --- /dev/null +++ b/docs/reports/2026-07-23-hmm-portal-full-browser-audit.md @@ -0,0 +1,49 @@ +# HMM 포털 전 화면 브라우저 전수검사 + +- 실행일: 2026-07-23 (Asia/Seoul) +- 대상: `https://hmm.cloud-handson.com/` +- 브라우저: Chromium, 1440×1100, OS dark color-scheme 모의 +- 추적: Redmine #699 / branch `hmm-backoffice` + +## 결과 + +| 화면 | 핵심 검증 | 결과 | +|---|---|---| +| 로그인 | 입력 2개, 로그인 유지, 실패 피드백, 서명 로그인, 로그아웃 | PASS | +| 아키텍처 | 운영 아키텍처, 처리 흐름, AI/MCP, 데이터 보안 | PASS | +| 시나리오 | 질문 입력, 샘플, 전송, 결과 empty state | PASS | +| 감사로그 | HMM 이벤트·상태 필터, 기간·건수, 새로고침, 상세 토글 | PASS | +| 보안관리 | 업무 프로세스, 백오피스 링크 및 HTTP 200 | PASS | +| 사이드바 | 데모 사용자 5명, 전환·복원, 검색, 다운로드, MCP cache | PASS | + +## 정량 결과 + +- 검사 화면: 5 +- 필수 문구 누락: 0 +- 낮은 색상 대비: 0 +- 가로 overflow: 0 +- 브라우저 page error: 0 +- console error: 0 +- HTTP 4xx/5xx: 0 + +시나리오의 “아직 저장된 대화가 없습니다.”와 감사로그의 “선택한 조건에 해당하는 HMM 접근 관리 +이벤트가 없습니다.”는 오류가 아니라 현재 데이터 0건을 설명하는 정상 empty state다. + +## 이번에 제거한 결함 + +1. 감사로그가 KB PoC의 `POC_2` FGA 메타데이터를 조회하던 경로를 제거했다. +2. 감사 원천을 `ADMIN.HMM_ACCESS_AUDIT`으로 교체하고 렌더러를 `ai-web-agent-console/ai_web_agent_console/audit.py`로 + 분리했다. +3. Python Thin driver가 암호화 `ewallet.pem`의 암호를 대화형으로 기다리던 연결을 ADB TLS 1521 + 서버 인증 풀로 교체했다. +4. Streamlit 최신 DOM의 `data-testid="stTab"`을 공통 CSS에서 직접 처리해 dark color-scheme에서 + 비활성 탭이 흰색으로 렌더링되던 문제를 제거했다. + +## 운영 배포 + +- 애플리케이션: `/opt/hmm-poc4` +- 서비스: `poc4-streamlit.service` (active) +- 감사 DB 환경 파일: `/etc/hmm-poc4-audit.env` (root-only) +- systemd drop-in: `/etc/systemd/system/poc4-streamlit.service.d/20-audit.conf` + +DB 사용자·암호·접속 기술자 원문은 이 보고서와 Git에 기록하지 않는다. diff --git a/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md b/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md index 9cea05f..65fb9fc 100644 --- a/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md +++ b/docs/runbooks/460-sqlcl-vpd-deploy-runbook.md @@ -25,9 +25,9 @@ SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@ SQLcl 안에서 실행: ```sql -@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter -@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql +@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql ``` 기대 결과: @@ -64,7 +64,7 @@ SQLCL_JAVA_HOME=/path/to/jdk \ 가장 단순한 롤백은 직전 Git revision의 `26_agent_ords_security_dynamic_vpd_filter.sql`을 다시 적용하는 것이다. ```bash -git show HEAD~1:sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql +git show HEAD~1:database/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@TNS" ``` diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md new file mode 100644 index 0000000..86e423e --- /dev/null +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -0,0 +1,159 @@ +# HMM MCP endpoint와 토큰 설정 + +## 인증 수단 구분 + +HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한다. + +| 인증 수단 | 사용 위치 | 전달 방식 | MCP 호출 사용 여부 | +|---|---|---|---| +| `__Host-HMM_PORTAL_SESSION` | `hmm.cloud-handson.com` 포털 로그인 | 브라우저 `Secure; HttpOnly` 쿠키 | 사용 금지 | +| `HMM_MCP_BEARER_TOKEN` | 별도 호환 MCP 서버 접근 | HTTP `Authorization: Bearer` | 포털의 공용 gateway에 사용 | +| HMM 직원별 VPD 토큰 | 백오피스 MCP 사용자 인증·DB context | HTTP `Authorization: Bearer` | Agent Factory 사용자별 연동에 사용 | + +포털 쿠키를 복사해 MCP Bearer Token으로 사용하면 안 된다. 브라우저 JavaScript에서도 포털 쿠키를 +읽을 수 없도록 `HttpOnly`로 설정한다. + +## MCP endpoint 구분 + +두 주소는 같은 토큰을 받지 않는다. + +| 항목 | 값 | +|---|---| +| 사용자별 VPD MCP | `https://hmm-backoffice.cloud-handson.com/mcp` | +| 사용자별 인증 | 백오피스에서 발급한 `vpd_live_*` 토큰 원문 | +| 공용 호환 MCP | `https://hmm-mcp.cloud-handson.com/mcp` | +| 공용 인증 | 운영 `HMM_MCP_BEARER_TOKEN` | +| Transport | Streamable HTTP POST | + +허용 도구는 다음 세 개다. + +| 도구 | 주요 인자 | 용도 | +|---|---|---| +| `search_hr_data` | `query` | 조직, 직원, 휴가 잔여·신청, 근태 조회 | +| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 | +| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 | + +## 포털의 현재 공용 토큰 조합 + +`config/vpd_token_presets.json`의 현재 조합은 다음과 같다. + +| 데모 사용자 | 역할 | `mcp_token_env` | +|---|---|---| +| E1001 Kim Minseo | HR Team Manager | `HMM_MCP_BEARER_TOKEN` | +| E1002 Lee Jiwon | HR Operations Specialist | `HMM_MCP_BEARER_TOKEN` | +| E1003 Park Dohyun | People Analytics Analyst | `HMM_MCP_BEARER_TOKEN` | +| E1005 Han Seojun | Recruiting Specialist | `HMM_MCP_BEARER_TOKEN` | +| E1007 Kang Minho | HR Coordinator | `HMM_MCP_BEARER_TOKEN` | + +현재는 모든 preset이 같은 서버 관리 토큰을 쓴다. preset을 바꾸면 질문에 포함되는 데모 사용자 +문맥은 바뀌지만, Bearer Token 자체는 바뀌지 않는다. 따라서 이 조합만으로는 사용자별 VPD +보안 경계를 만들지 못한다. + +## 포털 설정 + +`config/mcp_servers.json`에는 토큰 원문 대신 환경변수 이름만 기록한다. + +```json +{ + "id": "hmm_hr_mcp", + "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", + "auth_token_env": "HMM_MCP_BEARER_TOKEN", + "tool_allowlist": [ + "search_hr_data", + "resolve_hr_term", + "search_hr_policy" + ] +} +``` + +운영 서버 `/opt/hmm-poc4/.env`에 실제 값이 있어야 한다. + +```dotenv +HMM_MCP_BEARER_TOKEN= +POC3_MCP_TIMEOUT_SECONDS=45 +``` + +토큰 원문은 JSON, Git, 대화 기록, 화면 상세에 저장하지 않는다. 환경 파일은 운영 계정만 읽을 +수 있게 제한한다. + +## 사용자 VPD MCP 직접 호출 예시 + +```bash +export HMM_USER_BEARER_TOKEN='<백오피스 발급 화면에서 한 번 표시된 원문>' + +curl --fail-with-body \ + -H "Authorization: Bearer ${HMM_USER_BEARER_TOKEN}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + --data '{ + "jsonrpc": "2.0", + "id": "tools-list-1", + "method": "tools/list", + "params": {} + }' \ + https://hmm-backoffice.cloud-handson.com/mcp + +unset HMM_USER_BEARER_TOKEN +``` + +MCP 서버가 `initialize`와 session ID를 요구하면 다음 순서를 사용한다. + +1. `initialize` +2. 응답의 `Mcp-Session-Id` 보관 +3. `notifications/initialized` +4. 같은 session header와 Bearer Token으로 `tools/list` +5. 같은 session header와 Bearer Token으로 `tools/call` + +## Oracle AI Database Private Agent Factory 설정 + +| 입력 항목 | 값 | +|---|---| +| Server name | `hmm-backoffice-mcp` | +| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` | +| Authentication mode | Bearer Token | +| Token | 백오피스에서 해당 직원에게 발급한 토큰 원문. `Bearer ` 문자열은 붙이지 않음 | +| Allowed tools | 위 세 도구만 선택 | +| Timeout | 45초부터 시작 | + +이 endpoint는 OAuth authorization endpoint가 아니다. OAuth client ID, client secret, +authorization URL, token URL은 입력하지 않는다. + +## 사용자별 VPD 적용 구조 + +MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token + 직원 VPD token” 두 개를 +같은 header에 조합하지 않는다. 다음 구조가 권장된다. + +1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다. +2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다. +3. `ADMIN` 연결은 Select AI `SHOWSQL` 생성까지만 수행한다. +4. `EXEMPT ACCESS POLICY`가 없는 `CB_ORDS` 연결에서 + `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ' || :token)`을 호출한다. +5. 같은 `CB_ORDS` DB 세션에서 생성된 읽기 전용 SQL로 + `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. +6. 실행 전 런타임 사용자가 `ADMIN`이 아니고 `EXEMPT ACCESS POLICY`가 없으며, + `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치하는지 확인한다. +7. 하나라도 다르면 조회하지 않고 fail-closed 한다. +8. `finally`에서 `CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT`와 rollback을 수행한다. +9. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 + application identity와 user subject를 하나의 검증 가능한 access token으로 합친다. + +이 흐름은 2026-07-31 백오피스 MCP `search_hr_data`에 적용됐다. 모든 MCP method가 토큰 +해시·만료·회수·재직 상태를 확인한다. `search_hr_data`는 +`HMM_HR_DATA_GPT54_PROFILE`로 SQL만 생성하고, 실제 SQL은 위 `CB_ORDS` 세션에서 실행한다. +무토큰·무효·회수 토큰은 HTTP 401이다. + +운영 환경에는 다음 값이 필요하다. + +```dotenv +BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE +BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= +BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS +BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<운영 secret> +``` + +DB 구성 스크립트는 `database/adb/73_hmm_mcp_vpd_runtime.sql`이다. 이 스크립트는 승인된 HMM +HR 객체에 대한 개별 `SELECT`와 handler package 실행 권한만 부여하며 +`EXEMPT ACCESS POLICY`는 부여하지 않는다. + +포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다. +Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다. diff --git a/poc4_active_source_20260714/.env.sample b/poc4_active_source_20260714/.env.sample deleted file mode 100644 index a89bc8e..0000000 --- a/poc4_active_source_20260714/.env.sample +++ /dev/null @@ -1,129 +0,0 @@ -# PoC_4 runtime environment template -# -# 사용법: -# cp .env.sample .env -# chmod 600 .env -# 편집 후 scripts/poc4/start_*_nohup.sh 로 기동합니다. - -# 제품별 화면 값: 기본값은 config/app_profile.json, 아래 값이 있으면 환경변수가 우선합니다. -# AGENT_CONSOLE_NAME=HMM AI 업무 에이전트 -# AGENT_CONSOLE_SHORT_NAME=HMM -# AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트 -# AGENT_CONSOLE_PAGE_ICON=⛴️ -# AGENT_CONSOLE_HEADER_TITLE=AI 업무 에이전트 -# AGENT_CONSOLE_HEADER_DESCRIPTION=사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다. -# AGENT_CONSOLE_LOGIN_KICKER=HMM SHIPPING & LOGISTICS DEMO -# AGENT_CONSOLE_LOGIN_TITLE=HMM AI 업무 에이전트 -# AGENT_CONSOLE_LOGIN_DESCRIPTION=사용자 인증 후 해운·물류 AI 질의와 보안 관리 기능을 이용할 수 있습니다. -# AGENT_CONSOLE_LOGIN_FOOTER=인증된 DEMO 사용자만 접근할 수 있습니다. -# AGENT_CONSOLE_PRIMARY_COLOR=#003b70 -# AGENT_CONSOLE_TEXT_COLOR=#172b3a -# AGENT_CONSOLE_MUTED_COLOR=#667785 -# AGENT_CONSOLE_BORDER_COLOR=#dfe7ed -# -# 실제 token, password, OCID, wallet 경로는 이 샘플에 기록하지 않습니다. -# 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다. - -# ----------------------------------------------------------------------------- -# MCP runtime (현재 PoC_4가 공유하는 PoC_3 호환 환경변수 계약) -# ----------------------------------------------------------------------------- -POC3_MCP_PROVIDER=custom_python -POC3_MCP_BASE_URL=http://127.0.0.1:8500 -POC3_MCP_AUTH_MODE=bearer -POC3_MCP_TOKEN= -POC3_MCP_TIMEOUT_SECONDS=30 -POC3_MCP_FALLBACK_TO_MOCK=false -POC3_MCP_LIVE_SMOKE=false - -# MCP server registry -# 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다. -# 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다. -POC4_MCP_SERVERS_FILE=config/mcp_servers.json -POC4_MCP_DEFAULT_SERVER_ID=local_adb_mcp - -# MCP discovery UI conversation history store -# 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3 -POC4_CHAT_DB_PATH=data/poc4_mcp_chat.sqlite3 - -# ----------------------------------------------------------------------------- -# Local trace / optional LangSmith metadata -# ----------------------------------------------------------------------------- -POC3_TRACE_MODE=local -POC3_TRACE_UI_ENABLED=true -LANGSMITH_TRACING=false -LANGSMITH_API_KEY= -LANGSMITH_PROJECT=kb-aidp-poc4 - -# ----------------------------------------------------------------------------- -# PoC_4 UI ports -# 현재 VM 기본 포트만 사용합니다. 공식 포트 전환은 .env만으로 허용되지 않으며 -# POC4_ALLOW_OFFICIAL_PORTS=1을 기동 명령의 환경에 별도로 지정해야 합니다. -# ----------------------------------------------------------------------------- -POC4_LANGGRAPH_UI_PORT=8612 -POC4_AGENT_TEAM_UI_PORT=8613 -POC4_LANGGRAPH_TC_UI_PORT=8622 -POC4_AGENT_TEAM_TC_UI_PORT=8623 - -# ----------------------------------------------------------------------------- -# OCI Generative AI -# compartment ID는 배포 환경의 값을 입력합니다. API key/private key 원문은 넣지 -# 않고 OCI config file 또는 instance/resource principal을 사용합니다. -# ----------------------------------------------------------------------------- -OCI_AUTH_TYPE=config_file -OCI_CONFIG_FILE=~/.oci/config -OCI_PROFILE=DEFAULT -OCI_GENAI_COMPARTMENT_ID= - -# 모델 route는 config/poc3_model_profiles.json의 검증된 기본값을 사용합니다. -# 배포 환경에서 route를 바꿔야 할 때만 아래 항목의 주석을 해제합니다. -# POC3_LLM_GPT55_OCI_MODEL_ID=openai.gpt-5.5 -# POC3_LLM_GPT55_OCI_REGION=us-chicago-1 -# POC3_LLM_GPT55_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -# POC3_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini -# POC3_LLM_GPT54_MINI_OCI_REGION=us-chicago-1 -# POC3_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -# POC3_LLM_GROK43_MODEL_ID=xai.grok-4.3 -# POC3_LLM_GROK43_REGION=us-chicago-1 -# POC3_LLM_GROK43_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com -# 화면 LLM 모델 선택의 기본값입니다. -# 선택 모델은 후속 질문 정리, 실행 방식 판단, MCP tool 라우팅, -# Agent planning, 최종 답변 합성에 사용됩니다. -# 실패 시 애플리케이션 기본 fallback인 gpt54_mini_oci로 재시도합니다. -POC4_COMPLEX_REASONING_MODEL_PROFILE=grok43 - -# ----------------------------------------------------------------------------- -# Audit / security evidence DB connection -# ----------------------------------------------------------------------------- -# VPD 개발본 배포 서버 기준: -# - DB 접속 secret은 /home/opc/kbmcp/.env 에 둡니다. -# - DB wallet은 /home/opc/wallet/kbaipoc 를 사용합니다. -# 이 샘플에는 DB password, wallet password, token 원문을 넣지 않습니다. -POC4_AUDIT_DB_ENV_FILE=/home/opc/kbmcp/.env -ORACLE_WALLET_DIR=/home/opc/wallet/kbaipoc - -# ----------------------------------------------------------------------------- -# Direct Oracle DB administration tools (목표 계약) -# Streamlit/MCP-only 배포에서는 모두 비워 둡니다. 현재 launcher는 .env 전체를 UI -# process에 export하므로 역할별 설정 loader가 구현되기 전에는 이 파일에 DB secret을 -# 채우지 않습니다. DB migration/audit 전용 process environment에서만 주입합니다. -# ----------------------------------------------------------------------------- -POC4_DB_ADMIN_ENABLED=false -POC4_DB_USERNAME= -POC4_DB_PASSWORD= -POC4_DB_DSN= -POC4_DB_WALLET_DIR= -POC4_DB_WALLET_PASSWORD= -POC4_DB_EXPECTED_SCHEMA= -POC4_DB_CLIENT_LIB_DIR= -POC4_DB_MODE=thick -POC4_DB_CONNECT_TIMEOUT_SECONDS=60 -POC4_DB_CALL_TIMEOUT_MS=120000 - -# 신규 tenancy 이관 preflight에서만 사용하며 값 자체는 출력하지 않습니다. -POC4_EXPECTED_TENANCY_ID= - -# 다음 값은 복제된 .env가 보안/프로세스 제어를 바꾸지 못하도록 launcher 호출자만 -# 지정할 수 있습니다. 이 파일에 활성 값으로 추가하지 않습니다. -# POC4_BIND_ADDRESS=0.0.0.0 -# POC4_ALLOW_OFFICIAL_PORTS=1 -# POC4_MANAGED_FOREGROUND=1 diff --git a/poc4_active_source_20260714/SOURCE_README.md b/poc4_active_source_20260714/SOURCE_README.md deleted file mode 100644 index 7cababa..0000000 --- a/poc4_active_source_20260714/SOURCE_README.md +++ /dev/null @@ -1,61 +0,0 @@ -# HMM AI 업무 에이전트 소스 - -생성일: 2026-07-14 GMT - -이 폴더는 HMM AI 업무 에이전트 실행 소스다. `poc4`라는 디렉터리·환경변수 접두어는 -기존 배포 호환성을 위한 레거시 경로이며, 서비스의 정식 이름은 **HMM AI 업무 에이전트**다. - -## Entrypoint - -```bash -streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622 -``` - -## Runtime - -- Python 3.11 이상을 사용합니다. -- 이 개발 서버의 기본 `python3`가 3.6 계열이면 문법 검증이 실패합니다. -- 배포 서버 검증 런타임: `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` - -## Deployment DB reference - -- VPD 개발본 배포 서버의 DB 접속 정보는 `/home/opc/kbmcp/.env`를 사용합니다. -- DB wallet directory는 `/home/opc/wallet/kbaipoc`를 사용합니다. -- 이 저장소에는 wallet 파일, DB password, wallet password, 실제 VPD token 원문을 넣지 않습니다. - -## Included - -- `apps/poc4/mcp_discovery_ui.py` -- `src/agent_console/presentation.py` -- `src/agent_console/profile.py` -- `src/mcp_tool_router.py` -- `src/oci_genai_sdk.py` -- `src/poc3/model_registry.py` -- `src/poc3/questions.py` -- `config/mcp_servers.json` -- `config/poc3_model_profiles.json` -- `config/vpd_token_presets.json` -- `config/hmm_demo_scenarios.json` — 화면에 표시할 데모 질문. 코드 수정 없이 이 파일만 변경한다. -- `config/app_profile.json` — 제품명, 문구, 아이콘, 색상을 지정하는 제품 profile -- `.env.sample` -- `requirements.txt` -- `requirements-langgraph.txt` -- `scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh` -- `scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh` - -## Security note - -- 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요. -- 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요. -- 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다. - -## 구성 원칙 - -- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, 사용자 preset, 데모 질문 JSON -- `src/poc4/`: Streamlit과 분리 가능한 화면 보조 모듈 및 도메인 로직 -- `apps/poc4/`: 레거시 호환 entrypoint. 화면 조립과 사용자 상호작용만 담당하도록 점진적으로 축소 -- `tests/`: 설정 파일과 순수 Python 모듈의 회귀 검증 - -다른 프로젝트에 적용할 때는 scenario/MCP JSON만 교체하고, 제품명·설명·색상은 -`AGENT_CONSOLE_*` 환경변수로 지정합니다. 환경변수가 없을 때만 `config/app_profile.json`의 -중립 기본값을 사용합니다. diff --git a/poc4_active_source_20260714/config/vpd_token_presets.json b/poc4_active_source_20260714/config/vpd_token_presets.json deleted file mode 100644 index 494a426..0000000 --- a/poc4_active_source_20260714/config/vpd_token_presets.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "presets": [ - { - "enabled": true, - "default": true, - "token": "vpd_live_REPLACE_WITH_USER_TOKEN", - "user_id": "FC00789", - "name": "김설계", - "role": "설계사", - "channel": "설계사", - "scope": "본인 담당 계약 고객" - } - ] -} diff --git a/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz b/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz deleted file mode 100644 index 9df851e..0000000 Binary files a/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz and /dev/null differ diff --git a/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh b/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh deleted file mode 100755 index 58b03e4..0000000 --- a/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -source "/home/opc/poc_4/scripts/poc4/runtime_ui_lib.sh" -poc4_start_service "8622_langgraph_tc_ui" "/home/opc/poc_4/scripts/poc4/run_8622_langgraph_tc_ui.sh" "apps/poc4/langgraph_tc_ui.py" diff --git a/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh b/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh deleted file mode 100755 index 565daad..0000000 --- a/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -source "/home/opc/poc_4/scripts/poc4/runtime_ui_lib.sh" -poc4_status_service "8622_langgraph_tc_ui" "apps/poc4/langgraph_tc_ui.py" diff --git a/poc4_active_source_20260714/src/poc4/__init__.py b/poc4_active_source_20260714/src/poc4/__init__.py deleted file mode 100644 index ae16b2b..0000000 --- a/poc4_active_source_20260714/src/poc4/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""PoC4 UI support modules.""" diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py deleted file mode 100644 index 679f931..0000000 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - -from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios -from src.agent_console.profile import load_app_profile - - -class DemoScenarioConfigTest(unittest.TestCase): - 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_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)) - - 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) - - -if __name__ == "__main__": - unittest.main() diff --git a/run.sh b/run.sh index f7a3d2b..ea11a84 100755 --- a/run.sh +++ b/run.sh @@ -131,48 +131,48 @@ do_source() { PGPASSWORD="$PG_PASSWORD" psql \ -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ -v ON_ERROR_STOP=1 \ - -f "$ROOT/sql/source/postgres_setup.sql" + -f "$ROOT/database/source/postgres_setup.sql" ok "Postgres source 준비 완료" log "-- MySQL ($MY_HOST:$MY_PORT/$MY_DB) --" mysql \ -h "$MY_HOST" -P "$MY_PORT" -u "$MY_USER" "-p${MY_PASSWORD}" \ - "$MY_DB" < "$ROOT/sql/source/mysql_setup.sql" + "$MY_DB" < "$ROOT/database/source/mysql_setup.sql" ok "MySQL source 준비 완료" } do_adb() { log "=== adb: ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users ===" - run_sqlplus_file "$ROOT/sql/adb/00_cleanup.sql" - run_sqlplus_file "$ROOT/sql/adb/01_dblinks.sql" - run_sqlplus_file "$ROOT/sql/adb/02_perm_tables.sql" - run_sqlplus_file "$ROOT/sql/adb/03_seed.sql" - run_sqlplus_file "$ROOT/sql/adb/04_secure_ctx.sql" - run_sqlplus_file "$ROOT/sql/adb/05_views.sql" - run_sqlplus_file "$ROOT/sql/adb/06_policy.sql" - run_sqlplus_file "$ROOT/sql/adb/06a_redaction.sql" - run_sqlplus_file "$ROOT/sql/adb/07_end_users.sql" + run_sqlplus_file "$ROOT/database/adb/00_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/01_dblinks.sql" + run_sqlplus_file "$ROOT/database/adb/02_perm_tables.sql" + run_sqlplus_file "$ROOT/database/adb/03_seed.sql" + run_sqlplus_file "$ROOT/database/adb/04_secure_ctx.sql" + run_sqlplus_file "$ROOT/database/adb/05_views.sql" + run_sqlplus_file "$ROOT/database/adb/06_policy.sql" + run_sqlplus_file "$ROOT/database/adb/06a_redaction.sql" + run_sqlplus_file "$ROOT/database/adb/07_end_users.sql" ok "ADB setup 완료" } do_tests() { log "=== tests: 4-user access matrix 검증 ===" - run_sqlplus_as vpduser_my "$VPDUSER_MY_PASSWORD" "$ROOT/sql/adb/08_tests_user_my.sql" - run_sqlplus_as vpduser_pg "$VPDUSER_PG_PASSWORD" "$ROOT/sql/adb/09_tests_user_pg.sql" - run_sqlplus_as vpduser_both "$VPDUSER_BOTH_PASSWORD" "$ROOT/sql/adb/10_tests_user_both.sql" - run_sqlplus_as vpduser_none "$VPDUSER_NONE_PASSWORD" "$ROOT/sql/adb/11_tests_user_none.sql" + run_sqlplus_as vpduser_my "$VPDUSER_MY_PASSWORD" "$ROOT/database/adb/08_tests_user_my.sql" + run_sqlplus_as vpduser_pg "$VPDUSER_PG_PASSWORD" "$ROOT/database/adb/09_tests_user_pg.sql" + run_sqlplus_as vpduser_both "$VPDUSER_BOTH_PASSWORD" "$ROOT/database/adb/10_tests_user_both.sql" + run_sqlplus_as vpduser_none "$VPDUSER_NONE_PASSWORD" "$ROOT/database/adb/11_tests_user_none.sql" ok "4명 (MY / PG / BOTH / NONE) 테스트 실행 완료" } do_audit() { log "=== audit: admin 으로 정책 / 뷰 / 매트릭스 점검 ===" - run_sqlplus_file "$ROOT/sql/adb/12_tests_admin_audit.sql" + run_sqlplus_file "$ROOT/database/adb/12_tests_admin_audit.sql" ok "audit 완료" } do_backoffice_support() { log "=== backoffice-support: Spring Boot 백오피스 보조 객체 생성 ===" - run_sqlplus_file "$ROOT/sql/adb/25_agent_ords_security_backoffice_support.sql" + run_sqlplus_file "$ROOT/database/adb/25_agent_ords_security_backoffice_support.sql" ok "backoffice-support 완료" } @@ -191,7 +191,7 @@ do_backoffice_env_check() { do_teardown() { log "=== teardown: ADB 측 객체 + dblink/credential 정리 ===" warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)" - run_sqlplus_file "$ROOT/sql/adb/00_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/00_cleanup.sql" ok "teardown 완료" } @@ -209,24 +209,24 @@ do_dds_prereq() { do_dds_setup() { log "=== dds-setup: 13_dds_variant.sql (END USER + DATA ROLE + DATA GRANT) ===" # 멱등성: 부분 적용 상태를 먼저 청소한 뒤 재생성 - run_sqlplus_file "$ROOT/sql/adb/15_dds_cleanup.sql" - run_sqlplus_file "$ROOT/sql/adb/13_dds_variant.sql" + run_sqlplus_file "$ROOT/database/adb/15_dds_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/13_dds_variant.sql" ok "DDS 셋업 완료" } do_dds_tests() { log "=== dds-tests: 4-user (ddsuser_*) 매트릭스 검증 ===" # END USER 이름이 lowercase 라 사용자명을 큰따옴표로 감싸서 전달. - run_sqlplus_as '"ddsuser_my"' "$DDSUSER_MY_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" - run_sqlplus_as '"ddsuser_pg"' "$DDSUSER_PG_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" - run_sqlplus_as '"ddsuser_both"' "$DDSUSER_BOTH_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" - run_sqlplus_as '"ddsuser_none"' "$DDSUSER_NONE_PASSWORD" "$ROOT/sql/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_my"' "$DDSUSER_MY_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_pg"' "$DDSUSER_PG_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_both"' "$DDSUSER_BOTH_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" + run_sqlplus_as '"ddsuser_none"' "$DDSUSER_NONE_PASSWORD" "$ROOT/database/adb/14_tests_dds_user.sql" ok "DDS 4명 (MY / PG / BOTH / NONE) 테스트 실행 완료" } do_dds_teardown() { log "=== dds-teardown: DDS 객체만 정리 (VPD 데모는 보존) ===" - run_sqlplus_file "$ROOT/sql/adb/15_dds_cleanup.sql" + run_sqlplus_file "$ROOT/database/adb/15_dds_cleanup.sql" ok "DDS teardown 완료" } diff --git a/scripts/configure-backoffice-https-vm.sh b/scripts/configure-backoffice-https-vm.sh index 40a37bc..3eb7631 100755 --- a/scripts/configure-backoffice-https-vm.sh +++ b/scripts/configure-backoffice-https-vm.sh @@ -112,7 +112,7 @@ render_caddyfile() { gsub(/{{TLS_OPTIONS}}/, tls_options) print } - ' "$ROOT/deploy/caddy/Caddyfile.template" > "$output" + ' "$ROOT/deploy/vpd-backoffice/caddy/Caddyfile.template" > "$output" } build_ssh_args() { @@ -264,7 +264,7 @@ INSPECT log "Caddy candidate 업로드" ssh "${SSH_ARGS[@]}" "$HOST" "mkdir -p \"$remote_stage\"" - cp "$ROOT/deploy/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh" + cp "$ROOT/deploy/vpd-backoffice/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh" tar -C "$tmp_dir" -czf - Caddyfile install-caddy-config.sh \ | ssh "${SSH_ARGS[@]}" "$HOST" "tar -C \"$remote_stage\" -xzf - && chmod 700 \"$remote_stage/install-caddy-config.sh\"" diff --git a/scripts/deploy-backoffice-vm.sh b/scripts/deploy-backoffice-vm.sh index 5fc2451..393c242 100755 --- a/scripts/deploy-backoffice-vm.sh +++ b/scripts/deploy-backoffice-vm.sh @@ -32,7 +32,7 @@ Production transport defaults are always applied on the VM: HTTPS required, Secure/HttpOnly/SameSite session cookie. The script uploads: - - target/*.jar as app.jar + - vpd-backoffice/target/*.jar as app.jar - .env rewritten for the VM wallet path - TNS_ADMIN wallet directory - start.sh / stop.sh / status.sh @@ -127,11 +127,11 @@ ssh "${SSH_ARGS[@]}" "$HOST" 'printf "connected %s %s\n" "$(hostname)" "$(whoami if [[ "$SKIP_BUILD" != "Y" ]]; then log "jar 빌드" - mvn -DskipTests package + mvn -f "$ROOT/vpd-backoffice/pom.xml" -DskipTests package fi -JAR_PATH="$(find "$ROOT/target" -maxdepth 1 -type f -name '*.jar' ! -name '*sources.jar' ! -name '*javadoc.jar' | sort | tail -n 1)" -[[ -n "$JAR_PATH" && -f "$JAR_PATH" ]] || die "target jar를 찾을 수 없습니다. mvn package를 먼저 실행하세요." +JAR_PATH="$(find "$ROOT/vpd-backoffice/target" -maxdepth 1 -type f -name '*.jar' ! -name '*sources.jar' ! -name '*javadoc.jar' | sort | tail -n 1)" +[[ -n "$JAR_PATH" && -f "$JAR_PATH" ]] || die "vpd-backoffice target jar를 찾을 수 없습니다. 배포 빌드를 먼저 실행하세요." TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-deploy.XXXXXX")" cleanup() { diff --git a/scripts/deploy-dds-backoffice-local.sh b/scripts/deploy-dds-backoffice-local.sh index b5bc50b..99bf0b0 100755 --- a/scripts/deploy-dds-backoffice-local.sh +++ b/scripts/deploy-dds-backoffice-local.sh @@ -13,10 +13,10 @@ if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then fi cd "$ROOT" -mvn -DskipTests package +mvn -f "$ROOT/vpd-backoffice/pom.xml" -DskipTests package mvn install:install-file \ - -Dfile="$ROOT/target/vpd-permission-backoffice-0.1.0-SNAPSHOT.jar.original" \ - -DpomFile="$ROOT/pom.xml" + -Dfile="$ROOT/vpd-backoffice/target/vpd-permission-backoffice-0.1.0-SNAPSHOT.jar.original" \ + -DpomFile="$ROOT/vpd-backoffice/pom.xml" cd "$ROOT/dds-backoffice" mvn -DskipTests package diff --git a/scripts/provision-dds-token-search.sh b/scripts/provision-dds-token-search.sh index 74c9f29..322902f 100644 --- a/scripts/provision-dds-token-search.sh +++ b/scripts/provision-dds-token-search.sh @@ -15,8 +15,8 @@ connect="${DDS_ADMIN_USERNAME}/${DDS_ADMIN_PASSWORD}@${DDS_ADMIN_DB_URL}" sqlplus -s "$connect" < policyNames, - List schemaOwners, - List owners, - List functions, - List policyTemplates, - List statementTypes -) { - - public String defaultPermissionFunctionKey() { - return functions.stream() - .filter(function -> "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(function.functionName())) - .findFirst() - .map(VpdFunctionOption::value) - .orElse(""); - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java deleted file mode 100644 index 1765adf..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import java.util.List; -import org.springframework.stereotype.Service; - -/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */ -@Service -public class McpSseService { - - private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd"; - private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query"; - private static final String SELECT_AI_VPD_QUERY_PROFILE = - "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1"; - private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView( - SELECT_AI_VPD_QUERY_TOOL, - "GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.", - -1L, - "KB Select AI 행 접근 자연어 조회", - SELECT_AI_VPD_QUERY_PATH - ); - - private final SelectAiAgentOrdsService selectAiAgentOrdsService; - private final ObjectMapper objectMapper; - - public McpSseService( - SelectAiAgentOrdsService selectAiAgentOrdsService, - ObjectMapper objectMapper - ) { - this.selectAiAgentOrdsService = selectAiAgentOrdsService; - this.objectMapper = objectMapper; - } - - public ObjectNode handle(String contextPath, JsonNode request) { - return handle(contextPath, request, ""); - } - - /** - * The HTTP bearer token is the business-user subject token; no separate MCP token is used. - */ - public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) { - ObjectNode response = objectMapper.createObjectNode(); - response.put("jsonrpc", "2.0"); - if (request != null && request.has("id")) { - response.set("id", request.get("id")); - } - - String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); - JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params"); - try { - response.set("result", switch (method) { - case "initialize" -> initializeResult(contextPath); - case "notifications/initialized" -> objectMapper.createObjectNode(); - case "tools/list" -> toolsListResult(); - case "tools/call" -> toolsCallResult(parameters, vpdBearerToken); - default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); - }); - } catch (Exception e) { - response.remove("result"); - ObjectNode error = objectMapper.createObjectNode(); - error.put("code", -32000); - error.put("message", e.getMessage()); - response.set("error", error); - } - return response; - } - - /** The only tool registered by this MCP server. */ - public List registeredTools() { - return List.of(SELECT_AI_VPD_QUERY_VIEW); - } - - private ObjectNode initializeResult(String contextPath) { - ObjectNode result = objectMapper.createObjectNode(); - result.put("protocolVersion", "2024-11-05"); - ObjectNode serverInfo = objectMapper.createObjectNode(); - serverInfo.put("name", "vpd-ords-backoffice-" + contextPath); - serverInfo.put("version", "0.1.0"); - result.set("serverInfo", serverInfo); - ObjectNode capabilities = objectMapper.createObjectNode(); - capabilities.set("tools", objectMapper.createObjectNode()); - result.set("capabilities", capabilities); - return result; - } - - private ObjectNode toolsListResult() { - ObjectNode result = objectMapper.createObjectNode(); - ArrayNode tools = objectMapper.createArrayNode(); - tools.add(selectAiVpdQueryTool()); - result.set("tools", tools); - return result; - } - - private ObjectNode selectAiVpdQueryTool() { - ObjectNode item = objectMapper.createObjectNode(); - item.put("name", SELECT_AI_VPD_QUERY_TOOL); - item.put("description", SELECT_AI_VPD_QUERY_VIEW.description()); - - ObjectNode schema = objectMapper.createObjectNode(); - schema.put("type", "object"); - ObjectNode properties = objectMapper.createObjectNode(); - - ObjectNode prompt = objectMapper.createObjectNode(); - prompt.put("type", "string"); - prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다."); - prompt.put("maxLength", 4000); - properties.set("prompt", prompt); - - ObjectNode limit = objectMapper.createObjectNode(); - limit.put("type", "integer"); - limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다."); - limit.put("minimum", 1); - limit.put("maximum", 100); - properties.set("limit", limit); - - schema.set("properties", properties); - ArrayNode required = objectMapper.createArrayNode(); - required.add("prompt"); - schema.set("required", required); - schema.put("additionalProperties", false); - item.set("inputSchema", schema); - return item; - } - - private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { - String toolName = params.path("name").asText(""); - if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) { - throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); - } - - JsonNode arguments = params.path("arguments"); - String token = vpdBearerToken == null ? "" : vpdBearerToken.trim(); - if (token.isBlank()) { - return tokenAccessDeniedResult(); - } - JsonNode response; - try { - response = selectAiAgentOrdsService.run( - token, - arguments.path("prompt").asText(""), - normalizeLimit(arguments.path("limit").asInt(50)) - ); - } catch (VpdTokenAccessDeniedException ignored) { - return tokenAccessDeniedResult(); - } - - ObjectNode payload = objectMapper.createObjectNode(); - payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL); - payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE); - payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); - payload.set("response", response); - - ObjectNode result = objectMapper.createObjectNode(); - ArrayNode content = objectMapper.createArrayNode(); - ObjectNode text = objectMapper.createObjectNode(); - text.put("type", "text"); - text.put("text", pretty(payload)); - content.add(text); - result.set("content", content); - result.put("isError", false); - return result; - } - - private ObjectNode tokenAccessDeniedResult() { - ObjectNode payload = objectMapper.createObjectNode(); - payload.put("status", "VPD_TOKEN_DENIED"); - payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다."); - - ObjectNode result = objectMapper.createObjectNode(); - ArrayNode content = objectMapper.createArrayNode(); - ObjectNode text = objectMapper.createObjectNode(); - text.put("type", "text"); - text.put("text", pretty(payload)); - content.add(text); - result.set("content", content); - result.put("isError", true); - return result; - } - - private int normalizeLimit(int limit) { - if (limit < 1) { - return 50; - } - return Math.min(limit, 100); - } - - private String pretty(Object value) { - try { - return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); - } catch (Exception e) { - return String.valueOf(value); - } - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java deleted file mode 100644 index fab300f..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; -import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import org.springframework.core.io.ClassPathResource; -import org.springframework.stereotype.Service; - -/** - * Read-only catalogue of security deployment SQL bundled from the Git-tracked - * sql/adb directory. Script ids are an application whitelist: request input - * never becomes a filesystem or classpath path. - */ -@Service -public class SecuritySqlScriptService { - - private static final List CURATED_SCRIPTS = List.of( - new ScriptDefinition( - "aso-masking-metadata", - "ASO / 마스킹", - "62_kb_aso_masking_backoffice_metadata.sql", - "컬럼 마스킹 규칙 메타데이터", - "ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다." - ), - new ScriptDefinition( - "aso-masking-runtime", - "ASO / 마스킹", - "63_kb_aso_masking_rule_runtime.sql", - "ASO 마스킹 런타임 적용", - "백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다." - ), - new ScriptDefinition( - "aso-masking-default-columns", - "ASO / 마스킹", - "64_kb_aso_masking_default_column_rules.sql", - "ASO 기본 대상 컬럼", - "주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다." - ), - new ScriptDefinition( - "select-ai-vpd-api", - "Select AI / 행 접근", - "65_kb_select_ai_vpd_query_api.sql", - "행 접근 적용 Select AI 조회 API", - "생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다." - ), - new ScriptDefinition( - "select-ai-vpd-ords", - "ORDS / Select AI", - "66_kb_select_ai_vpd_query_ords.sql", - "Select AI 행 접근 ORDS Endpoint", - "Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다." - ) - ); - - public List list() { - return CURATED_SCRIPTS.stream() - .map(definition -> new SecuritySqlScriptSummary( - definition.scriptId(), - definition.category(), - definition.fileName(), - definition.title(), - definition.description() - )) - .toList(); - } - - public SecuritySqlScript find(String scriptId) { - ScriptDefinition definition = CURATED_SCRIPTS.stream() - .filter(candidate -> candidate.scriptId().equals(scriptId)) - .findFirst() - .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); - return new SecuritySqlScript( - definition.scriptId(), - definition.category(), - definition.fileName(), - definition.title(), - definition.description(), - readSource(definition.fileName()) - ); - } - - private String readSource(String fileName) { - ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName); - try (InputStream input = resource.getInputStream()) { - return new String(input.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new AppException("배포된 보안 SQL 스크립트를 읽을 수 없습니다: " + fileName); - } - } - - private record ScriptDefinition( - String scriptId, - String category, - String fileName, - String title, - String description - ) { - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java deleted file mode 100644 index f2d09f5..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; -import java.util.List; -import java.util.Map; -import org.springframework.dao.DataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -@Service -public class StructuredDataService { - - private static final String OWNER = "SGMP_POC"; - private static final int ROW_LIMIT = 50; - private static final List TABLES = List.of( - new StructuredDataTable("game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터"), - new StructuredDataTable("characters", "CZN_COMN_CHARACTER_MST", "캐릭터", "카제나 캐릭터 마스터"), - new StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"), - new StructuredDataTable("refunds", "COMN_REFUND_TXN", "환불 거래", "게임 상품 환불 거래"), - new StructuredDataTable("products", "COMN_SALES_PRODUCT_DISP_BAS", "상품", "판매 상품 전시 기준"), - new StructuredDataTable("game-servers", "COMN_GAME_SERVER_BAS", "게임 서버", "게임 서버 기준 정보"), - new StructuredDataTable("game-aliases", "COMN_GAME_ALIAS_BAS", "게임 별칭", "게임명·별칭·prefix 매핑")); - - private final JdbcTemplate jdbcTemplate; - - public StructuredDataService(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - public List tables() { - return TABLES; - } - - public String defaultKey() { - return TABLES.getFirst().key(); - } - - public StructuredDataTable requireTable(String key) { - return TABLES.stream() - .filter(table -> table.key().equals(key)) - .findFirst() - .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); - } - - public StructuredDataPreview preview(String key) { - StructuredDataTable table = requireTable(key); - try { - List columns = jdbcTemplate.query( - """ - SELECT column_name - FROM all_tab_columns - WHERE owner = ? - AND table_name = ? - ORDER BY column_id - """, - (resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName()); - if (columns.isEmpty()) { - throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); - } - - List> rows = jdbcTemplate.queryForList( - previewSql(table), ROW_LIMIT); - return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); - } catch (DataAccessException exception) { - throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요."); - } - } - - /** - * The table is selected from a closed application whitelist, so the query - * text remains fixed and no request value can become a SQL identifier. - */ - private String previewSql(StructuredDataTable table) { - return switch (table.key()) { - case "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?"; - case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?"; - case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?"; - case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?"; - case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?"; - case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?"; - case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?"; - default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); - }; - } -} diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml deleted file mode 100644 index 03bf808..0000000 --- a/src/main/resources/mapper/UserMapper.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - INSERT INTO sg_app_user (user_id, login_id, user_name, employee_no, dept_code, can_read_contents, active) - VALUES (#{userId}, LOWER(#{command.empNo}), #{command.username}, #{command.empNo}, - #{command.deptCode}, 'N', 'Y') - - - - UPDATE sg_app_user SET active = #{activeYn} WHERE user_id = #{userId} - - - - INSERT INTO sg_user_role (user_id, role_id) VALUES (#{userId}, #{roleId}) - - - - DELETE FROM sg_user_role WHERE user_id = #{userId} - AND role_id = #{roleId} - - diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java deleted file mode 100644 index a8ece9b..0000000 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class MaskingPolicySynchronizerTest { - - @Test - void maskingExpressionIsAParseableSqlConditionForRedactionExpressionBindValue() { - assertThat(MaskingPolicySynchronizer.maskingExpression(11)) - .isEqualTo( - "SYS_CONTEXT('CB_AGENT_CTX', 'MR_11') IS NULL " - + "OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_11') <> 'Y'"); - } - - @Test - void maskingExpressionDoesNotUsePlsqlLiteralEscapingBecauseJdbcBindsTheValue() { - assertThat(MaskingPolicySynchronizer.maskingExpression(11)) - .doesNotContain("''CB_AGENT_CTX''") - .doesNotContain("''MR_11''") - .doesNotContain("''Y''"); - } - - @Test - void kbContractsPremiumPolicyIsManagedByBackoffice() { - assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS")) - .isEqualTo("KB_CONTRACT_PREMIUM_REDACT"); - } -} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java deleted file mode 100644 index b9cf90c..0000000 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import static org.assertj.core.api.Assertions.assertThat; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.jupiter.api.Test; - -class McpSseServiceTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService(); - private final McpSseService service = new McpSseService( - selectAiAgentOrdsService, - objectMapper - ); - - @Test - void listsOnlyVpdSelectAiToolWithPromptInput() { - ObjectNode response = service.handle("default", request(1, "tools/list")); - - var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(1); - var selectAi = tools.get(0); - assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd"); - assertThat(selectAi.path("inputSchema").path("required")) - .extracting(node -> node.asText()) - .contains("prompt"); - assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); - assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue(); - assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); - } - - @Test - void callsVpdSelectAiThroughOrdsService() { - ObjectNode request = request(2, "tools/call"); - ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "ords.query.kb_select_ai_vpd"); - ObjectNode arguments = params.putObject("arguments"); - arguments.put("prompt", "고객 수를 조회해 줘"); - arguments.put("limit", 25); - - ObjectNode response = service.handle("default", request, "user-bearer"); - - CapturingSelectAiAgentOrdsService agentService = - (CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService; - assertThat(agentService.bearerToken).isEqualTo("user-bearer"); - assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘"); - assertThat(agentService.limit).isEqualTo(25); - assertThat(response.path("error").isMissingNode()).isTrue(); - assertThat(response.path("result").path("isError").asBoolean()).isFalse(); - assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") - .contains("SELECT COUNT(*) FROM KB_CUSTOMERS"); - } - - @Test - void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { - ObjectNode request = request(3, "tools/call"); - ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "ords.query.kb_select_ai_vpd"); - params.putObject("arguments").put("prompt", "고객 수를 조회해 줘"); - - ObjectNode response = service.handle("default", request, ""); - - assertThat(response.path("error").isMissingNode()).isTrue(); - assertThat(response.path("result").path("isError").asBoolean()).isTrue(); - assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("VPD_TOKEN_DENIED") - .contains("권한이 없습니다"); - } - - private ObjectNode request(int id, String method) { - ObjectNode request = objectMapper.createObjectNode(); - request.put("jsonrpc", "2.0"); - request.put("id", id); - request.put("method", method); - return request; - } - - private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService { - - private String bearerToken; - private String prompt; - private int limit; - - private CapturingSelectAiAgentOrdsService() { - super(null, null, new ObjectMapper()); - } - - @Override - public JsonNode run(String bearerToken, String prompt, int limit) { - this.bearerToken = bearerToken; - this.prompt = prompt; - this.limit = limit; - return new ObjectMapper().createObjectNode() - .put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") - .put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS"); - } - } -} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java deleted file mode 100644 index 934b908..0000000 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import org.junit.jupiter.api.Test; - -class SecuritySqlScriptServiceTest { - - private final SecuritySqlScriptService service = new SecuritySqlScriptService(); - - @Test - void exposesOnlyTheCuratedGitTrackedSecurityScripts() { - assertThat(service.list()) - .extracting(item -> item.fileName()) - .containsExactly( - "62_kb_aso_masking_backoffice_metadata.sql", - "63_kb_aso_masking_rule_runtime.sql", - "64_kb_aso_masking_default_column_rules.sql", - "65_kb_select_ai_vpd_query_api.sql", - "66_kb_select_ai_vpd_query_ords.sql" - ); - - assertThat(service.find("select-ai-vpd-ords").source()) - .contains("POST /ords/cb-ords/kb-select-ai-vpd/query") - .contains("Authorization: Bearer "); - } - - @Test - void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() { - assertThatThrownBy(() -> service.find("../../etc/passwd")) - .isInstanceOf(AppException.class) - .hasMessageContaining("조회할 수 없는"); - } -} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java deleted file mode 100644 index 8b549fa..0000000 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; - -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; - -class StructuredDataServiceTest { - - private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); - - @Test - void exposesOnlyTheSevenApprovedStructuredTables() { - assertThat(service.tables()) - .extracting(table -> table.tableName()) - .containsExactly( - "KB_CUSTOMERS", - "KB_PRODUCTS", - "KB_CONTRACTS", - "KB_COVERAGES", - "KB_CLAIMS", - "KB_EXTERNAL_HOLDINGS", - "KB_STAKEHOLDERS"); - } - - @Test - void rejectsAnyTableOutsideTheServerSideAllowlist() { - assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG")) - .isInstanceOf(AppException.class) - .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); - } -} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c5e466b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,11 @@ +# 저장소 구조 검증 + +이 폴더는 특정 애플리케이션의 단위 테스트가 아니라 저장소 전체의 폴더 경계와 경로 +규칙을 검증한다. + +```bash +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +애플리케이션 기능 테스트는 각각 `vpd-backoffice/src/test`와 +`ai-web-agent-console/tests`에 둔다. diff --git a/tests/test_repository_layout.py b/tests/test_repository_layout.py new file mode 100644 index 0000000..f77152b --- /dev/null +++ b/tests/test_repository_layout.py @@ -0,0 +1,58 @@ +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class RepositoryLayoutTest(unittest.TestCase): + def test_application_boundaries_are_explicit(self) -> None: + for relative in ( + "vpd-backoffice/pom.xml", + "vpd-backoffice/src/main", + "ai-web-agent-console/app.py", + "ai-web-agent-console/ai_web_agent_console", + "dds-backoffice/pom.xml", + "database/adb", + "deploy/vpd-backoffice", + "deploy/ai-web-agent-console", + ): + self.assertTrue((ROOT / relative).exists(), relative) + + def test_ambiguous_legacy_roots_are_absent(self) -> None: + for relative in ( + "pom.xml", + "src", + "sql", + "poc4_active_source_20260714", + ): + self.assertFalse((ROOT / relative).exists(), relative) + + def test_console_has_one_named_python_package(self) -> None: + console = ROOT / "ai-web-agent-console" + self.assertFalse((console / "src").exists()) + self.assertFalse((console / "apps").exists()) + self.assertTrue((console / "ai_web_agent_console" / "__init__.py").is_file()) + + source = "\n".join( + path.read_text(encoding="utf-8") + for path in (console / "ai_web_agent_console").glob("*.py") + ) + source += (console / "app.py").read_text(encoding="utf-8") + for forbidden in ( + "from src.", + "src.poc3", + "src.poc4", + "src.agent_console", + "apps/poc4", + ): + self.assertNotIn(forbidden, source) + + def test_vpd_backoffice_packages_database_sql(self) -> None: + pom = (ROOT / "vpd-backoffice" / "pom.xml").read_text(encoding="utf-8") + self.assertIn("../database/adb", pom) + self.assertNotIn("sql/adb", pom) + + +if __name__ == "__main__": + unittest.main() diff --git a/pom.xml b/vpd-backoffice/pom.xml similarity index 86% rename from pom.xml rename to vpd-backoffice/pom.xml index 8fc9b81..d49518b 100644 --- a/pom.xml +++ b/vpd-backoffice/pom.xml @@ -92,17 +92,13 @@ src/main/resources - + - sql/adb - sql/adb + ../database/adb + database/adb - 62_kb_aso_masking_backoffice_metadata.sql - 63_kb_aso_masking_rule_runtime.sql - 64_kb_aso_masking_default_column_rules.sql - 65_kb_select_ai_vpd_query_api.sql - 66_kb_select_ai_vpd_query_ords.sql + **/*.sql diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java similarity index 62% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java index 539c791..ab9520a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java @@ -6,7 +6,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties(BackofficeProperties.class) +@EnableConfigurationProperties({ + BackofficeProperties.class, + CatalogProperties.class, + MaskingProperties.class, + McpProperties.class, + ProductProperties.class, + SecuritySqlScriptProperties.class +}) public class AppConfig { @Bean diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java similarity index 66% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index a959001..3b31783 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -8,7 +8,8 @@ public record BackofficeProperties( Security security, Token token, Ords ords, - Ai ai + Ai ai, + SelectAi selectAi ) { public record Security( @@ -72,4 +73,31 @@ public record BackofficeProperties( this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", ""); } } + + /** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */ + public record SelectAi( + String dbUrl, + String dbUsername, + String dbPassword, + String profile, + String runtimeDbUrl, + String runtimeDbUsername, + String runtimeDbPassword, + String queryContractFile + ) { + + public boolean configured() { + return dbUrl != null && !dbUrl.isBlank() + && dbUsername != null && !dbUsername.isBlank() + && dbPassword != null && !dbPassword.isBlank() + && profile != null && !profile.isBlank(); + } + + /** The generated SQL must never fall back to the privileged profile-owner connection. */ + public boolean runtimeConfigured() { + return runtimeDbUrl != null && !runtimeDbUrl.isBlank() + && runtimeDbUsername != null && !runtimeDbUsername.isBlank() + && runtimeDbPassword != null && !runtimeDbPassword.isBlank(); + } + } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java new file mode 100644 index 0000000..38f20df --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Deployment-provided allow-list for the structured-data and metadata screens. */ +@ConfigurationProperties(prefix = "backoffice.catalog") +public record CatalogProperties(String owner, String objects) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java new file mode 100644 index 0000000..ed31458 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** JSON configuration of database redaction policies this backoffice may manage. */ +@ConfigurationProperties(prefix = "backoffice.masking") +public record MaskingProperties(String policies) { +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java new file mode 100644 index 0000000..acf1c19 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -0,0 +1,54 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Product-neutral MCP endpoint labels and tool catalogue configuration. */ +@ConfigurationProperties(prefix = "backoffice.mcp") +public record McpProperties( + String publicUrl, + String serverName, + String toolName, + String toolLabel, + String toolDescription, + String promptDescription, + String tools +) { + + private static final String DEFAULT_PUBLIC_URL = "/mcp"; + private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice"; + private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; + private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL"; + private static final String DEFAULT_TOOL_DESCRIPTION = + "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 " + + "트랜잭션에서 실행합니다."; + private static final String DEFAULT_PROMPT_DESCRIPTION = + "업무 데이터에서 조회할 내용을 자연어로 입력합니다."; + + public String resolvedPublicUrl() { + return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL); + } + + public String resolvedServerName() { + return requiredOrDefault(serverName, DEFAULT_SERVER_NAME); + } + + public String resolvedToolName() { + return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); + } + + public String resolvedToolLabel() { + return requiredOrDefault(toolLabel, DEFAULT_TOOL_LABEL); + } + + public String resolvedToolDescription() { + return requiredOrDefault(toolDescription, DEFAULT_TOOL_DESCRIPTION); + } + + public String resolvedPromptDescription() { + return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION); + } + + private String requiredOrDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java new file mode 100644 index 0000000..a311670 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java @@ -0,0 +1,20 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Customer-facing labels that do not affect authorization or database identity. */ +@ConfigurationProperties(prefix = "backoffice.product") +public record ProductProperties(String name, String title, String dataLabel) { + + public String displayName() { + return name == null || name.isBlank() ? "Data & AI Backoffice" : name.trim(); + } + + public String pageTitle() { + return title == null || title.isBlank() ? displayName() : title.trim(); + } + + public String dataName() { + return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java new file mode 100644 index 0000000..69201f3 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Deployment-provided allow-list for bundled security SQL shown by the backoffice. */ +@ConfigurationProperties(prefix = "backoffice.security-sql-scripts") +public record SecuritySqlScriptProperties(String scripts) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/EffectiveMatrixView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/GroupEffectiveAccessView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/RoleEffectiveImpactView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/effective/UserEffectiveAccessView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationHealthSummary.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerUpdateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsHandlerView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/ords/OrdsObjectHandlerResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java similarity index 89% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java index 7c9532b..2409b08 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java @@ -67,11 +67,14 @@ public record PermissionView( if (upper.startsWith("STATIC_SQL ")) { return "정적 SQL 조건: " + rawRule.substring("STATIC_SQL ".length()); } - if (upper.contains(" MY_DEPT")) { + if ("MY_DEPT".equals(upper) || upper.contains(" MY_DEPT")) { return "내 부서 행"; } - if (upper.contains(" SELF")) { - return "내 사번/소유자 행"; + if ("MANAGED_TEAM".equals(upper) || upper.contains(" MANAGED_TEAM")) { + return "본인 및 직접 보고 팀원 행"; + } + if ("SELF".equals(upper) || upper.contains(" SELF")) { + return "본인 직원 행"; } if (upper.contains(" TAG ")) { return "태그 조건: " + rawRule; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/FgaExecutionEvidence.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionCandidate.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/SqlExecutionEvidence.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/DatabaseObjectOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/setting/BackofficeSetting.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java new file mode 100644 index 0000000..446c88a --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -0,0 +1,21 @@ +package com.cloudhandson.vpdbackoffice.domain.structured; + +import java.util.List; + +public record StructuredDataTable( + String key, + String tableName, + String objectType, + String businessName, + String description, + List previewColumns +) { + + public StructuredDataTable(String key, String tableName, String businessName, String description) { + this(key, tableName, "TABLE", businessName, description, List.of()); + } + + public boolean isTable() { + return "TABLE".equals(objectType); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/UserRoleView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdBulkApplyResult.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java similarity index 88% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java index 50b844f..4406ca6 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java @@ -17,6 +17,7 @@ public record VpdFunctionOption( } public boolean permissionSystemDefault() { - return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName); + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) + || "HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionSource.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdObjectFilterDetail.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyCreateCommand.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyDetail.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyExplanation.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java new file mode 100644 index 0000000..61407c0 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java @@ -0,0 +1,33 @@ +package com.cloudhandson.vpdbackoffice.domain.vpd; + +import java.util.List; + +public record VpdPolicyFormOptions( + List policyNames, + List schemaOwners, + List owners, + List functions, + List policyTemplates, + List statementTypes +) { + + public String defaultPermissionFunctionKey() { + return functions.stream() + .filter(VpdFunctionOption::permissionSystemDefault) + .findFirst() + .map(VpdFunctionOption::value) + .orElse(""); + } + + public String defaultPermissionFunctionKey(String objectName) { + String preferredFunction = objectName != null + && objectName.toUpperCase(java.util.Locale.ROOT).startsWith("HMM_LEAVE_") + ? "HMM_LEAVE_VPD_FILTER" + : "CB_AGENT_DOC_VPD_FILTER"; + return functions.stream() + .filter(function -> preferredFunction.equalsIgnoreCase(function.functionName())) + .findFirst() + .map(VpdFunctionOption::value) + .orElseGet(this::defaultPermissionFunctionKey); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyTemplateOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java similarity index 92% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java index e872eb7..a4eaba5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java @@ -26,6 +26,7 @@ public record VpdPolicyView( } public boolean permissionSystemDefault() { - return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName); + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) + || "HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdSchemaObjectOption.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java similarity index 88% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index ca4edb8..7c908ee 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -5,6 +5,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; +import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -28,7 +29,10 @@ public interface MaskingRuleMapper { List findColumnRules(); - List findPolicyStatuses(); + List findPolicyStatuses( + @Param("owner") String owner, + @Param("policies") List policies + ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OperationStatusMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/OrdsMetadataMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java similarity index 87% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java index 572b70a..3d661ee 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java @@ -10,4 +10,6 @@ public interface SettingMapper { BackofficeSetting findByKey(@Param("settingKey") String settingKey); void upsert(@Param("settingKey") String settingKey, @Param("settingValue") String settingValue); + + void deleteByKey(@Param("settingKey") String settingKey); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java similarity index 92% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 2712457..173901d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -333,18 +333,16 @@ public class BackofficeSchemaService { ); } - addCountCheck(checks, "VPD Runtime", "CB_AGENT_CTX", "CONTEXT", - "SELECT COUNT(*) FROM all_context WHERE namespace = 'CB_AGENT_CTX'", - "sqlcl에서 17_agent_ords_security_local_vpd_setup.sql 또는 CREATE CONTEXT를 실행하세요.", + addCountCheck(checks, "VPD Runtime", "HMM_ACCESS_CTX", "CONTEXT", + "SELECT COUNT(*) FROM all_context WHERE namespace = 'HMM_ACCESS_CTX'", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요.", true); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CTX_PKG", "PACKAGE", - "sqlcl에서 cb_agent_ctx_pkg package를 생성/컴파일하세요."); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CTX_PKG BODY", "PACKAGE BODY", - "sqlcl에서 cb_agent_ctx_pkg package body를 생성/컴파일하세요.", "CB_AGENT_CTX_PKG"); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_DOC_VPD_FILTER", "FUNCTION", - "sqlcl에서 26_agent_ords_security_dynamic_vpd_filter.sql을 실행하세요."); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CAN_READ_COLUMN", "FUNCTION", - "sqlcl에서 backoffice support redaction function을 생성/컴파일하세요."); + addUserObjectCheck(checks, "VPD Runtime", "HMM_ACCESS_CTX_PKG", "PACKAGE", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요."); + addUserObjectCheck(checks, "VPD Runtime", "HMM_ACCESS_CTX_PKG BODY", "PACKAGE BODY", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요.", "HMM_ACCESS_CTX_PKG"); + addUserObjectCheck(checks, "VPD Runtime", "HMM_LEAVE_VPD_FILTER", "FUNCTION", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요."); addPolicyCheck(checks); addCountCheck(checks, "ORDS Runtime", ORDS_SCHEMA, "USER", @@ -360,8 +358,6 @@ public class BackofficeSchemaService { addAllObjectCheck(checks, "ORDS Runtime", ORDS_SCHEMA, "CB_ORDS_HANDLER_PKG", "PACKAGE", "CB_ORDS로 접속해 22_agent_ords_security_ords_handler_setup.sql을 실행하세요."); - addGrantCheck(checks, currentUser, "CB_AGENT_CTX_PKG", "EXECUTE"); - addGrantCheck(checks, currentUser, "CB_AGENT_CAN_READ_COLUMN", "EXECUTE"); addProtectedObjectGrantCheck(checks); return new SchemaPreflightView(currentUser, ORDS_SCHEMA, checks, appDdlPreview(), sqlclScript(currentUser)); @@ -449,6 +445,12 @@ public class BackofficeSchemaService { } private void seedDefaultSettings(List results) { + if (properties.ords().baseUrl() == null || properties.ords().baseUrl().isBlank()) { + results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "SKIPPED", + "HMM 기본 구성은 ORDS를 사용하지 않습니다. 레거시 ORDS 연동이 필요한 경우에만 설정하세요.", + SETTINGS_MERGE_SQL)); + return; + } try { jdbcTemplate.update(SETTINGS_MERGE_SQL, properties.ords().baseUrl()); results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED", @@ -591,14 +593,14 @@ public class BackofficeSchemaService { SELECT COUNT(*) FROM all_policies WHERE pf_owner = USER - AND function = 'CB_AGENT_DOC_VPD_FILTER' + AND function = 'HMM_LEAVE_VPD_FILTER' """); checks.add(new SchemaPreflightCheck( "VPD Runtime", - "CB_AGENT_DOC_VPD_FILTER 적용 policy", + "HMM_LEAVE_VPD_FILTER 적용 policy", "DBMS_RLS POLICY", count > 0 ? "OK" : "WARN", - count > 0 ? count + "개 policy가 기본 filter를 사용합니다." : "기본 filter가 적용된 TABLE/VIEW가 없습니다.", + count > 0 ? count + "개 HMM 휴가 policy가 기본 filter를 사용합니다." : "HMM 휴가 filter가 적용된 TABLE/VIEW가 없습니다.", count > 0 ? "조치 없음" : "운영 > VPD 설정에서 개별 또는 벌크 적용을 실행하세요.", false )); @@ -634,9 +636,14 @@ public class BackofficeSchemaService { } private void addProtectedObjectGrantCheck(List checks) { - if (!exists("SELECT COUNT(*) FROM user_tables WHERE table_name = 'CB_PROTECTED_OBJECT'")) { + if (!exists(""" + SELECT COUNT(*) + FROM user_objects + WHERE object_name = 'CB_PROTECTED_OBJECT' + AND object_type IN ('TABLE', 'VIEW') + """)) { checks.add(new SchemaPreflightCheck("Grants", "보호 객체 SELECT grants", "GRANT", "WARN", - "CB_PROTECTED_OBJECT 테이블이 없어 등록 보호 객체 grant를 계산할 수 없습니다.", + "CB_PROTECTED_OBJECT 객체가 없어 등록 보호 객체 grant를 계산할 수 없습니다.", "지원 테이블 생성/확인 후 다시 점검하세요.", false)); return; } @@ -700,26 +707,16 @@ public class BackofficeSchemaService { private String sqlclScript(String currentUser) { String owner = currentUser == null || currentUser.isBlank() ? "ADMIN" : currentUser; return """ - -- sqlcl에서 실행할 VPD/ORDS runtime 준비 순서 - -- 1. ADMIN 또는 보호 객체 owner로 접속 - @sql/adb/17_agent_ords_security_local_vpd_setup.sql - @sql/adb/25_agent_ords_security_backoffice_support.sql - @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql - @sql/adb/62_kb_aso_masking_backoffice_metadata.sql - @sql/adb/21_agent_ords_security_ords_enable_schema.sql + -- sqlcl에서 실행할 HMM VPD runtime 준비 순서 + -- 1. ADMIN으로 접속 + @database/adb/25_agent_ords_security_backoffice_support.sql + @database/adb/72_hmm_leave_team_vpd.sql - -- 2. ORDS parsing schema로 접속 - CONNECT cb_ords/@ - @sql/adb/22_agent_ords_security_ords_handler_setup.sql - - -- 3. 대표 권한 부여 SQL + -- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한 CONNECT %s/@ - GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; - GRANT SELECT ON . TO cb_ords; - - -- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행 - @sql/adb/64_kb_aso_masking_default_column_rules.sql - @sql/adb/63_kb_aso_masking_rule_runtime.sql + GRANT EXECUTE ON hmm_access_ctx_pkg TO ; + GRANT SELECT ON hmm_leave_balances TO ; + GRANT SELECT ON hmm_leave_requests TO ; """.formatted(owner.toLowerCase()); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java new file mode 100644 index 0000000..b63fede --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java @@ -0,0 +1,13 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import java.util.List; + +public interface DataCatalog { + + String owner(); + + List objects(); + + StructuredDataTable require(String key); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EffectiveMatrixService.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java new file mode 100644 index 0000000..0f07ffa --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java @@ -0,0 +1,122 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.CatalogProperties; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Validated deployment allow-list for data preview and metadata operations. */ +@Service +public class EnvironmentDataCatalog implements DataCatalog { + + private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + private static final Pattern KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + + private final String owner; + private final List objects; + + public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper objectMapper) { + try { + owner = requireName(properties.owner(), "BACKOFFICE_CATALOG_OWNER"); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.", exception); + } + objects = parse(properties.objects(), objectMapper); + } + + @Override + public String owner() { + return owner; + } + + @Override + public List objects() { + return objects; + } + + @Override + public StructuredDataTable require(String key) { + return objects.stream() + .filter(item -> item.key().equals(key)) + .findFirst() + .orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다.")); + } + + private List parse(String raw, ObjectMapper objectMapper) { + if (raw == null || raw.isBlank()) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요."); + } + try { + List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("카탈로그 객체가 비어 있습니다."); + } + Set keys = new HashSet<>(); + Set objectNames = new HashSet<>(); + List normalized = parsed.stream() + .map(this::normalize) + .peek(item -> { + if (!keys.add(item.key())) { + throw new IllegalArgumentException("중복 key: " + item.key()); + } + if (!objectNames.add(item.tableName())) { + throw new IllegalArgumentException("중복 tableName: " + item.tableName()); + } + }) + .toList(); + return List.copyOf(normalized); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception); + } + } + + private StructuredDataTable normalize(StructuredDataTable value) { + if (value == null) { + throw new IllegalArgumentException("null 카탈로그 객체"); + } + String key = requiredText(value.key(), "key").toLowerCase(Locale.ROOT); + if (!KEY.matcher(key).matches()) { + throw new IllegalArgumentException("잘못된 key: " + value.key()); + } + String objectName = requireName(value.tableName(), "tableName"); + String objectType = requiredText(value.objectType(), "objectType").toUpperCase(Locale.ROOT); + if (!Set.of("TABLE", "VIEW").contains(objectType)) { + throw new IllegalArgumentException("잘못된 objectType: " + value.objectType()); + } + List previewColumns = value.previewColumns() == null + ? List.of() + : value.previewColumns().stream() + .map(column -> requireName(column, "previewColumns")) + .distinct() + .toList(); + return new StructuredDataTable( + key, + objectName, + objectType, + requiredText(value.businessName(), "businessName"), + requiredText(value.description(), "description"), + List.copyOf(previewColumns) + ); + } + + private String requireName(String value, String field) { + String normalized = requiredText(value, field).toUpperCase(Locale.ROOT); + if (!NAME.matcher(normalized).matches()) { + throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다."); + } + return normalized; + } + + private String requiredText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " 값은 필수입니다."); + } + return value.trim(); + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java new file mode 100644 index 0000000..e6559ab --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java @@ -0,0 +1,75 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.MaskingProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Loads the managed Data Redaction allow-list from deployment configuration. */ +@Service +public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog { + + private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + + private final List targets; + + public EnvironmentMaskingPolicyCatalog( + MaskingProperties properties, + ObjectMapper objectMapper + ) { + targets = parse(properties.policies(), objectMapper); + } + + @Override + public List targets() { + return targets; + } + + private List parse(String raw, ObjectMapper objectMapper) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + try { + List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("마스킹 정책 목록이 비어 있습니다."); + } + Set objectNames = new HashSet<>(); + Set policyNames = new HashSet<>(); + List normalized = parsed.stream() + .map(item -> { + if (item == null) { + throw new IllegalArgumentException("null 마스킹 정책"); + } + return new MaskingPolicyTarget( + normalize(item.objectName()), + normalize(item.policyName())); + }) + .peek(item -> { + if (!objectNames.add(item.objectName())) { + throw new IllegalArgumentException("중복 objectName: " + item.objectName()); + } + if (!policyNames.add(item.policyName())) { + throw new IllegalArgumentException("중복 policyName: " + item.policyName()); + } + }) + .toList(); + return List.copyOf(normalized); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_MASKING_POLICIES 설정을 확인하세요.", exception); + } + } + + private String normalize(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!NAME.matcher(normalized).matches()) { + throw new IllegalArgumentException("Oracle 식별자 형식이 올바르지 않습니다."); + } + return normalized; + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java new file mode 100644 index 0000000..66caabf --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java @@ -0,0 +1,136 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.McpProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Loads MCP tool contracts while retaining the #722 single Select AI compatibility fields. */ +@Service +public class EnvironmentMcpToolCatalog implements McpToolCatalog { + + private static final Pattern TOOL_NAME = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final Pattern ARGUMENT_NAME = + Pattern.compile("[A-Za-z][A-Za-z0-9_]{0,63}"); + private static final Pattern ORACLE_NAME = + Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + + private final List tools; + + public EnvironmentMcpToolCatalog( + McpProperties properties, + ObjectMapper objectMapper + ) { + tools = parse(properties, objectMapper); + } + + @Override + public List tools() { + return tools; + } + + @Override + public McpToolDefinition require(String name) { + return tools.stream() + .filter(tool -> tool.name().equals(name)) + .findFirst() + .orElseThrow(() -> new AppException("등록되지 않은 MCP tool입니다: " + name)); + } + + private List parse( + McpProperties properties, + ObjectMapper objectMapper + ) { + String raw = properties.tools(); + if (raw == null || raw.isBlank()) { + return List.of(new McpToolDefinition( + properties.resolvedToolName(), + properties.resolvedToolLabel(), + properties.resolvedToolDescription(), + "prompt", + properties.resolvedPromptDescription(), + "SELECT_AI", + "", + "" + )); + } + try { + List parsed = + objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("MCP 도구 목록이 비어 있습니다."); + } + Set names = new HashSet<>(); + List normalized = parsed.stream() + .map(this::normalize) + .peek(tool -> { + if (!names.add(tool.name())) { + throw new IllegalArgumentException("중복 MCP tool name: " + tool.name()); + } + }) + .toList(); + return List.copyOf(normalized); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_MCP_TOOLS 설정을 확인하세요.", exception); + } + } + + private McpToolDefinition normalize(McpToolDefinition value) { + if (value == null) { + throw new IllegalArgumentException("null MCP tool"); + } + String name = required(value.name(), "name"); + String argumentName = required(value.argumentName(), "argumentName"); + if (!TOOL_NAME.matcher(name).matches()) { + throw new IllegalArgumentException("잘못된 MCP tool name"); + } + if (!ARGUMENT_NAME.matcher(argumentName).matches()) { + throw new IllegalArgumentException("잘못된 MCP argumentName"); + } + String executionType = required(value.executionType(), "executionType") + .toUpperCase(Locale.ROOT); + if (!Set.of("AGENT_TOOL", "SELECT_AI").contains(executionType)) { + throw new IllegalArgumentException("잘못된 MCP executionType"); + } + String targetName = value.targetName() == null ? "" : value.targetName().trim(); + String targetParameterName = + value.targetParameterName() == null ? "" : value.targetParameterName().trim(); + if ("AGENT_TOOL".equals(executionType)) { + targetName = oracleName(targetName, "targetName"); + targetParameterName = oracleName(targetParameterName, "targetParameterName"); + } else if (!targetName.isBlank() || !targetParameterName.isBlank()) { + throw new IllegalArgumentException("SELECT_AI에는 targetName을 지정할 수 없습니다."); + } + return new McpToolDefinition( + name, + required(value.label(), "label"), + required(value.description(), "description"), + argumentName, + required(value.argumentDescription(), "argumentDescription"), + executionType, + targetName, + targetParameterName + ); + } + + private String oracleName(String value, String field) { + String normalized = required(value, field).toUpperCase(Locale.ROOT); + if (!ORACLE_NAME.matcher(normalized).matches()) { + throw new IllegalArgumentException("잘못된 " + field); + } + return normalized; + } + + private String required(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " 값은 필수입니다."); + } + return value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java new file mode 100644 index 0000000..81f6c80 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java @@ -0,0 +1,11 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** Executes a pre-approved HMM DBMS_CLOUD_AI_AGENT tool. */ +@FunctionalInterface +public interface HmmAiAgentToolRunner { + + JsonNode run(String toolName, ObjectNode input, String bearerToken); +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java new file mode 100644 index 0000000..b28a925 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmKnowledgeSchemaInitializer.java @@ -0,0 +1,143 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Keeps the HMM knowledge store as the source of truth while preserving the + * CB_VECTOR_* contracts that the existing ORDS and backoffice screens use. + */ +@Component +@Order(0) +public class HmmKnowledgeSchemaInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(HmmKnowledgeSchemaInitializer.class); + + private static final List TABLES = List.of( + new SchemaObject("HMM_KNOWLEDGE_DOCUMENTS", """ + CREATE TABLE hmm_knowledge_documents ( + document_id VARCHAR2(200) PRIMARY KEY, + file_name VARCHAR2(500) NOT NULL, + source_uri VARCHAR2(1000), + abstract_text CLOB, + original_text CLOB, + file_content BLOB, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL + ) + """), + new SchemaObject("HMM_KNOWLEDGE_CHUNKS", """ + CREATE TABLE hmm_knowledge_chunks ( + chunk_id NUMBER PRIMARY KEY, + document_id VARCHAR2(200) NOT NULL + REFERENCES hmm_knowledge_documents(document_id) ON DELETE CASCADE, + chunk_no NUMBER NOT NULL, + title VARCHAR2(500), + chunk_text CLOB NOT NULL, + source_uri VARCHAR2(1000), + embedding VECTOR, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT hmm_knowledge_chunk_uk UNIQUE (document_id, chunk_no) + ) + """), + new SchemaObject("HMM_KNOWLEDGE_TAGS", """ + CREATE TABLE hmm_knowledge_tags ( + chunk_id NUMBER NOT NULL + REFERENCES hmm_knowledge_chunks(chunk_id) ON DELETE CASCADE, + tech_tag VARCHAR2(100) NOT NULL, + CONSTRAINT hmm_knowledge_tag_pk PRIMARY KEY (chunk_id, tech_tag), + CONSTRAINT hmm_knowledge_tag_format_ck + CHECK (REGEXP_LIKE(tech_tag, '^[A-Z0-9_-]+$')) + ) + """) + ); + + private final JdbcTemplate jdbcTemplate; + + public HmmKnowledgeSchemaInitializer(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void run(org.springframework.boot.ApplicationArguments args) { + TABLES.forEach(this::ensureTable); + ensureSequence(); + ensureIndex(); + ensureLegacyViews(); + log.info("HMM knowledge schema and CB_VECTOR compatibility views are ready"); + } + + private void ensureTable(SchemaObject table) { + if (!objectExists(table.name(), "TABLE")) { + jdbcTemplate.execute(table.ddl()); + } + } + + private void ensureSequence() { + if (!objectExists("HMM_KNOWLEDGE_CHUNK_SEQ", "SEQUENCE")) { + jdbcTemplate.execute("CREATE SEQUENCE hmm_knowledge_chunk_seq START WITH 30000 INCREMENT BY 1 NOCACHE"); + } + } + + private void ensureIndex() { + if (!objectExists("HMM_KNOWLEDGE_TAG_IX", "INDEX")) { + jdbcTemplate.execute("CREATE INDEX hmm_knowledge_tag_ix ON hmm_knowledge_tags (tech_tag, chunk_id)"); + } + } + + private void ensureLegacyViews() { + replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_CHUNK", """ + CREATE OR REPLACE VIEW cb_vector_document_chunk AS + SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding, created_at + FROM hmm_knowledge_chunks + """); + replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_TAG", """ + CREATE OR REPLACE VIEW cb_vector_document_tag AS + SELECT chunk_id, tech_tag + FROM hmm_knowledge_tags + """); + replaceViewUnlessPhysicalTable("CB_VECTOR_SEARCH_DOCUMENTS", """ + CREATE OR REPLACE VIEW cb_vector_search_documents AS + SELECT c.chunk_id, + c.document_id, + c.chunk_no, + c.title, + c.chunk_text, + c.source_uri, + c.embedding, + (SELECT LISTAGG(t.tech_tag, ',') WITHIN GROUP (ORDER BY t.tech_tag) + FROM hmm_knowledge_tags t + WHERE t.chunk_id = c.chunk_id) AS tech_tag + FROM hmm_knowledge_chunks c + WHERE EXISTS ( + SELECT 1 FROM hmm_knowledge_tags t WHERE t.chunk_id = c.chunk_id + ) + """); + } + + private void replaceViewUnlessPhysicalTable(String name, String ddl) { + if (objectExists(name, "TABLE")) { + log.info("{} is a physical legacy table; leaving it unchanged", name); + return; + } + jdbcTemplate.execute(ddl); + } + + private boolean objectExists(String name, String type) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM user_objects + WHERE object_name = ? + AND object_type = ? + """, Integer.class, name, type); + return count != null && count > 0; + } + + private record SchemaObject(String name, String ddl) { + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java new file mode 100644 index 0000000..5fc7139 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpBearerAuthenticator.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Validates an HMM backoffice bearer token without retaining its plaintext value. */ +@FunctionalInterface +public interface HmmMcpBearerAuthenticator { + + HmmMcpPrincipal authenticate(String bearerToken); +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java new file mode 100644 index 0000000..75b9843 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmMcpPrincipal.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Authenticated HMM employee identity resolved from a one-way bearer-token hash. */ +public record HmmMcpPrincipal( + long employeeId, + String employeeCode, + Long teamId +) { +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java new file mode 100644 index 0000000..7d4c110 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java @@ -0,0 +1,52 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting; +import com.cloudhandson.vpdbackoffice.mapper.SettingMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Component; + +/** Removes the retired KB demo ORDS address without touching an operator-supplied ORDS endpoint. */ +@Component +@Order(1) +public class HmmRuntimeSettingsInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(HmmRuntimeSettingsInitializer.class); + private static final String RETIRED_KB_ORDS_URL = + "https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords"; + + private final SettingMapper settingMapper; + + public HmmRuntimeSettingsInitializer(SettingMapper settingMapper) { + this.settingMapper = settingMapper; + } + + @Override + public void run(ApplicationArguments args) { + try { + BackofficeSetting setting = settingMapper.findByKey(SettingService.ORDS_BASE_URL); + if (setting != null && RETIRED_KB_ORDS_URL.equals(normalize(setting.settingValue()))) { + settingMapper.deleteByKey(SettingService.ORDS_BASE_URL); + log.info("Removed retired KB demo ORDS setting; HMM MCP is the active integration"); + } + } catch (DataAccessException exception) { + // Do not prevent the backoffice from starting while its support schema is being prepared. + log.warn("Skipped HMM runtime setting migration: {}", exception.getMostSpecificCause().getMessage()); + } + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + String normalized = value.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java new file mode 100644 index 0000000..cb53f17 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java @@ -0,0 +1,78 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.TextNode; +import java.sql.CallableStatement; +import java.sql.Clob; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** JDBC implementation shared with the standalone HMM MCP facade. */ +@Service +public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + public JdbcHmmAiAgentToolRunner(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + } + + @Override + public JsonNode run( + String toolName, + com.fasterxml.jackson.databind.node.ObjectNode input, + String bearerToken + ) { + try { + String request = objectMapper.writeValueAsString(input); + String response = jdbcTemplate.execute((ConnectionCallback) connection -> { + boolean contextSet = false; + try { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) { + statement.setString(1, bearerToken); + statement.execute(); + contextSet = true; + } + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?)) + FROM dual + """)) { + statement.setString(1, toolName); + statement.setString(2, request); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return ""; + } + Clob clob = resultSet.getClob(1); + return clob == null ? "" : clob.getSubString(1, (int) clob.length()); + } + } + } finally { + if (contextSet) { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + statement.execute(); + } + } + } + }); + if (response == null || response.isBlank()) { + return objectMapper.createObjectNode(); + } + try { + return objectMapper.readTree(response); + } catch (Exception ignored) { + return TextNode.valueOf(response); + } + } catch (Exception exception) { + throw new AppException("HMM AI Agent Tool 실행에 실패했습니다: " + exception.getMessage()); + } + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java new file mode 100644 index 0000000..a343091 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmMcpBearerAuthenticator.java @@ -0,0 +1,45 @@ +package com.cloudhandson.vpdbackoffice.service; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** Resolves active HMM employee tokens using only their SHA-256 hashes in ADB. */ +@Service +public class JdbcHmmMcpBearerAuthenticator implements HmmMcpBearerAuthenticator { + + private static final int MAX_TOKEN_LENGTH = 4096; + + private final JdbcTemplate jdbcTemplate; + + public JdbcHmmMcpBearerAuthenticator(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public HmmMcpPrincipal authenticate(String bearerToken) { + if (bearerToken == null || bearerToken.isBlank() || bearerToken.length() > MAX_TOKEN_LENGTH) { + throw new McpUnauthorizedException(); + } + try { + return jdbcTemplate.queryForObject(""" + SELECT employee.employee_id, + employee.employee_code, + employee.team_id + FROM hmm_access_bearer_tokens token + JOIN hmm_hr_employees employee + ON employee.employee_id = token.employee_id + WHERE token.key_hash = STANDARD_HASH(?, 'SHA256') + AND token.revoked_at IS NULL + AND token.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP) + AND employee.employment_status = 'ACTIVE' + """, (resultSet, rowNum) -> new HmmMcpPrincipal( + resultSet.getLong("employee_id"), + resultSet.getString("employee_code"), + resultSet.getObject("team_id", Long.class) + ), bearerToken); + } catch (EmptyResultDataAccessException exception) { + throw new McpUnauthorizedException(); + } + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java new file mode 100644 index 0000000..d224b67 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java @@ -0,0 +1,22 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +public interface MaskingPolicyCatalog { + + List targets(); + + default Set objectNames() { + return targets().stream() + .map(MaskingPolicyTarget::objectName) + .collect(Collectors.toUnmodifiableSet()); + } + + default boolean containsObject(String objectName) { + return objectName != null + && objectNames().contains(objectName.trim().toUpperCase(Locale.ROOT)); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java similarity index 85% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index ce4b4ae..04c6b28 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -24,37 +23,47 @@ import org.springframework.stereotype.Service; @Service public class MaskingPolicySynchronizer { - private static final String OWNER = "POC_2"; private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); - private static final Map MANAGED_POLICIES = managedPolicyMap(); private final JdbcTemplate jdbcTemplate; private final MaskingRuleMapper mapper; + private final DataCatalog dataCatalog; + private final MaskingPolicyCatalog policyCatalog; - public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) { + public MaskingPolicySynchronizer( + JdbcTemplate jdbcTemplate, + MaskingRuleMapper mapper, + DataCatalog dataCatalog, + MaskingPolicyCatalog policyCatalog + ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - } - - private static Map managedPolicyMap() { - Map policies = new LinkedHashMap<>(); - policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT"); - policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT"); - policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT"); - policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT"); - return Collections.unmodifiableMap(policies); + this.dataCatalog = dataCatalog; + this.policyCatalog = policyCatalog; } public Set managedObjectNames() { - return MANAGED_POLICIES.keySet(); + return policyCatalog.objectNames(); + } + + public String owner() { + return dataCatalog.owner(); } public boolean isManagedObject(String objectName) { - return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); + return policyCatalog.containsObject(objectName); } - static String managedPolicyName(String objectName) { - return MANAGED_POLICIES.get(objectName); + String managedPolicyName(String objectName) { + return policyCatalog.targets().stream() + .filter(target -> target.objectName().equals(objectName)) + .map(MaskingPolicyTarget::policyName) + .findFirst() + .orElse(null); + } + + List managedPolicies() { + return policyCatalog.targets(); } /** @@ -67,9 +76,9 @@ public class MaskingPolicySynchronizer { public MaskingPolicySyncResult synchronize() { Map> desiredByObject = new LinkedHashMap<>(); for (ColumnMaskingRule rule : mapper.findColumnRules()) { - if (OWNER.equalsIgnoreCase(rule.owner()) + if (dataCatalog.owner().equalsIgnoreCase(rule.owner()) && rule.ruleEnabled() - && MANAGED_POLICIES.containsKey(rule.objectName())) { + && policyCatalog.containsObject(rule.objectName())) { desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); } } @@ -79,9 +88,9 @@ public class MaskingPolicySynchronizer { int addedColumns = 0; int modifiedColumns = 0; int droppedColumns = 0; - for (Map.Entry policy : MANAGED_POLICIES.entrySet()) { - String objectName = policy.getKey(); - String policyName = policy.getValue(); + for (MaskingPolicyTarget policy : policyCatalog.targets()) { + String objectName = policy.objectName(); + String policyName = policy.policyName(); List desired = desiredByObject.getOrDefault(objectName, List.of()); String enableStatus = policyEnableStatus(objectName, policyName); if (desired.isEmpty()) { @@ -141,7 +150,7 @@ public class MaskingPolicySynchronizer { SELECT enable FROM redaction_policies WHERE object_owner = ? AND object_name = ? AND policy_name = ? - """, String.class, OWNER, objectName, policyName); + """, String.class, dataCatalog.owner(), objectName, policyName); return statuses.isEmpty() ? null : statuses.getFirst(); } @@ -150,7 +159,7 @@ public class MaskingPolicySynchronizer { SELECT column_name FROM redaction_columns WHERE object_owner = ? AND object_name = ? - """, String.class, OWNER, objectName).stream() + """, String.class, dataCatalog.owner(), objectName).stream() .map(this::requiredColumnName) .toList(); } @@ -160,7 +169,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void enablePolicy(String objectName, String policyName) { @@ -168,7 +177,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void dropColumn(String objectName, String policyName, String columnName) { @@ -179,7 +188,7 @@ public class MaskingPolicySynchronizer { action => DBMS_REDACT.DROP_COLUMN, column_name => ? ); END; - """, OWNER, objectName, policyName, columnName); + """, dataCatalog.owner(), objectName, policyName, columnName); } private void addPolicy( @@ -251,9 +260,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } return; } @@ -276,9 +285,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(actionConstant, functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } } @@ -314,7 +323,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, OWNER, objectName, columnName, expressionName); + """, dataCatalog.owner(), objectName, columnName, expressionName); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java new file mode 100644 index 0000000..2f60dc3 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java @@ -0,0 +1,5 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** A validated database object-to-redaction-policy mapping. */ +public record MaskingPolicyTarget(String objectName, String policyName) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java similarity index 96% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index 05335de..b520f56 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -55,9 +55,13 @@ public class MaskingRuleService { return mapper.findColumnRules(); } - /** Reads the actual Oracle Data Redaction state for the three managed KB objects. */ + /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */ public List findPolicyStatuses() { - return mapper.findPolicyStatuses(); + List policies = maskingPolicySynchronizer.managedPolicies(); + if (policies.isEmpty()) { + return List.of(); + } + return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies); } public Set managedObjectNames() { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java new file mode 100644 index 0000000..7c2705b --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -0,0 +1,188 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; +import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import org.springframework.stereotype.Service; + +/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */ +@Service +public class McpSseService { + + private static final String MCP_CALL_PATH = "/mcp (tools/call)"; + + private final HmmAiAgentToolRunner agentToolRunner; + private final SelectAiService selectAiService; + private final HmmMcpBearerAuthenticator bearerAuthenticator; + private final McpToolCatalog toolCatalog; + private final McpProperties mcpProperties; + private final BackofficeProperties backofficeProperties; + private final ObjectMapper objectMapper; + + public McpSseService( + HmmAiAgentToolRunner agentToolRunner, + SelectAiService selectAiService, + HmmMcpBearerAuthenticator bearerAuthenticator, + McpToolCatalog toolCatalog, + McpProperties mcpProperties, + BackofficeProperties backofficeProperties, + ObjectMapper objectMapper + ) { + this.agentToolRunner = agentToolRunner; + this.selectAiService = selectAiService; + this.bearerAuthenticator = bearerAuthenticator; + this.toolCatalog = toolCatalog; + this.mcpProperties = mcpProperties; + this.backofficeProperties = backofficeProperties; + this.objectMapper = objectMapper; + } + + public ObjectNode handle(String contextPath, JsonNode request) { + return handle(contextPath, request, ""); + } + + /** Discovery and execution use the same HMM business-user Bearer token boundary. */ + public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) { + bearerAuthenticator.authenticate(bearerToken); + ObjectNode response = objectMapper.createObjectNode(); + response.put("jsonrpc", "2.0"); + if (request != null && request.has("id")) { + response.set("id", request.get("id")); + } + + String method = + request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); + JsonNode parameters = + request == null ? objectMapper.createObjectNode() : request.path("params"); + try { + response.set("result", switch (method) { + case "initialize" -> initializeResult(contextPath); + case "notifications/initialized" -> objectMapper.createObjectNode(); + case "tools/list" -> toolsListResult(); + case "tools/call" -> toolsCallResult(parameters, bearerToken); + default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); + }); + } catch (McpUnauthorizedException exception) { + throw exception; + } catch (Exception exception) { + response.remove("result"); + ObjectNode error = objectMapper.createObjectNode(); + error.put("code", -32000); + error.put("message", safeMessage(exception)); + response.set("error", error); + } + return response; + } + + public List registeredTools() { + return toolCatalog.tools().stream() + .map(tool -> new McpToolView( + tool.name(), + tool.description(), + -1L, + tool.label(), + tool.agentTool() ? tool.targetName() : MCP_CALL_PATH + )) + .toList(); + } + + private ObjectNode initializeResult(String contextPath) { + ObjectNode result = objectMapper.createObjectNode(); + result.put("protocolVersion", "2024-11-05"); + ObjectNode serverInfo = objectMapper.createObjectNode(); + serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath); + serverInfo.put("version", "1.1.0"); + result.set("serverInfo", serverInfo); + ObjectNode capabilities = objectMapper.createObjectNode(); + capabilities.set("tools", objectMapper.createObjectNode()); + result.set("capabilities", capabilities); + return result; + } + + private ObjectNode toolsListResult() { + ObjectNode result = objectMapper.createObjectNode(); + ArrayNode tools = objectMapper.createArrayNode(); + toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool))); + result.set("tools", tools); + return result; + } + + private ObjectNode toolDefinition(McpToolDefinition tool) { + ObjectNode item = objectMapper.createObjectNode(); + item.put("name", tool.name()); + item.put("description", tool.description()); + ObjectNode schema = objectMapper.createObjectNode(); + schema.put("type", "object"); + ObjectNode properties = objectMapper.createObjectNode(); + ObjectNode argument = objectMapper.createObjectNode(); + argument.put("type", "string"); + argument.put("description", tool.argumentDescription()); + argument.put("maxLength", 4000); + properties.set(tool.argumentName(), argument); + schema.set("properties", properties); + ArrayNode required = objectMapper.createArrayNode(); + required.add(tool.argumentName()); + schema.set("required", required); + schema.put("additionalProperties", false); + item.set("inputSchema", schema); + return item; + } + + private ObjectNode toolsCallResult(JsonNode params, String bearerToken) { + McpToolDefinition tool = toolCatalog.require(params.path("name").asText("")); + String argument = params.path("arguments").path(tool.argumentName()).asText("").trim(); + if (argument.isBlank()) { + throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다."); + } + + JsonNode toolResponse; + if (tool.agentTool()) { + ObjectNode input = objectMapper.createObjectNode(); + input.put(tool.targetParameterName(), argument); + toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken); + } else { + toolResponse = selectAiService.generateAndExecute(bearerToken, argument); + } + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("toolName", tool.name()); + payload.put("executionType", tool.executionType()); + if (tool.agentTool()) { + payload.put("agentTool", tool.targetName()); + } else { + BackofficeProperties.SelectAi selectAi = + backofficeProperties == null ? null : backofficeProperties.selectAi(); + payload.put("profile", selectAi == null || selectAi.profile() == null + ? "" : selectAi.profile()); + } + payload.set("response", toolResponse); + + ObjectNode result = objectMapper.createObjectNode(); + ArrayNode content = objectMapper.createArrayNode(); + ObjectNode text = objectMapper.createObjectNode(); + text.put("type", "text"); + text.put("text", pretty(payload)); + content.add(text); + result.set("content", content); + result.put("isError", false); + return result; + } + + private String safeMessage(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message; + } + + private String pretty(Object value) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (Exception exception) { + return String.valueOf(value); + } + } +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java new file mode 100644 index 0000000..2c68bbb --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.service; + +import java.util.List; + +public interface McpToolCatalog { + + List tools(); + + McpToolDefinition require(String name); +} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java new file mode 100644 index 0000000..b2f1487 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java @@ -0,0 +1,22 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Validated MCP tool contract supplied by deployment configuration. */ +public record McpToolDefinition( + String name, + String label, + String description, + String argumentName, + String argumentDescription, + String executionType, + String targetName, + String targetParameterName +) { + + public boolean agentTool() { + return "AGENT_TOOL".equals(executionType); + } + + public boolean selectAi() { + return "SELECT_AI".equals(executionType); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java new file mode 100644 index 0000000..f60b453 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Deliberately generic MCP authentication failure; never include token material. */ +public class McpUnauthorizedException extends RuntimeException { + + public McpUnauthorizedException() { + super("유효한 HMM 사용자 Bearer Token이 필요합니다."); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OperationStatusService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java similarity index 98% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index 3117b3d..4672421 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -23,14 +23,14 @@ import org.springframework.transaction.annotation.Transactional; public class PermissionService { private static final Set RULE_TYPES = Set.of( - "ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", + "ALL", "=", "!=", "MANAGED_TEAM", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "TOKEN_SUBJECT", "OWN_CONTRACT", "CHANNEL_CONTRACT", "OWN_CUSTOMER", "CHANNEL_CUSTOMER", "STATIC_SQL"); private static final Set VALUE_REQUIRED_RULE_TYPES = Set.of( "=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "STATIC_SQL"); private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of( - "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STATIC_SQL"); + "MANAGED_TEAM", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STATIC_SQL"); private static final Set STATIC_SQL_KEYWORDS = Set.of( "AND", "OR", "NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "ESCAPE", "TRUE", "FALSE", "UPPER", "LOWER", "TRIM", "NVL", "COALESCE", "TO_CHAR", "TO_DATE", "REGEXP_LIKE", "INSTR", diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java similarity index 92% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index e818362..256bc8d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -18,7 +18,6 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SchemaMetadataService { - private static final String OWNER = "SGMP_POC"; private static final int MAX_COMMENT_LENGTH = 4000; private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000; private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); @@ -35,6 +34,10 @@ public class SchemaMetadataService { return structuredDataService.tables(); } + public DataCatalog catalog() { + return structuredDataService.catalog(); + } + public String defaultKey() { return structuredDataService.defaultKey(); } @@ -43,7 +46,8 @@ public class SchemaMetadataService { StructuredDataTable table = structuredDataService.requireTable(tableKey); String tableName = table.tableName(); String tableComment = tableComment(tableName); - Map> annotations = annotationsByTarget(tableName); + Map> annotations = + table.isTable() ? annotationsByTarget(tableName) : Map.of(); List columns = columns(tableName, annotations); return new SchemaMetadataView( table, @@ -73,6 +77,7 @@ public class SchemaMetadataService { @Transactional public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) { StructuredDataTable table = structuredDataService.requireTable(tableKey); + requireAnnotationTable(table); updateAnnotation(table.tableName(), null, annotationName, annotationValue); } @@ -84,6 +89,7 @@ public class SchemaMetadataService { String annotationValue ) { StructuredDataTable table = structuredDataService.requireTable(tableKey); + requireAnnotationTable(table); String column = requireColumn(table.tableName(), columnName); updateAnnotation(table.tableName(), column, annotationName, annotationValue); } @@ -111,7 +117,7 @@ public class SchemaMetadataService { FROM all_tab_comments WHERE owner = ? AND table_name = ? - """, (rs, rowNum) -> rs.getString(1), OWNER, tableName); + """, (rs, rowNum) -> rs.getString(1), owner(), tableName); return values.isEmpty() ? "" : values.getFirst(); } @@ -146,7 +152,7 @@ public class SchemaMetadataService { "Y".equalsIgnoreCase(rs.getString("nullable")), nullToEmpty(rs.getString("comments")), annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of()) - ), OWNER, tableName); + ), owner(), tableName); } private Map> annotationsByTarget(String tableName) { @@ -216,13 +222,19 @@ public class SchemaMetadataService { WHERE owner = ? AND table_name = ? AND column_name = ? - """, Integer.class, OWNER, tableName, column); + """, Integer.class, owner(), tableName, column); if (count == null || count == 0) { throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); } return column; } + private void requireAnnotationTable(StructuredDataTable table) { + if (!table.isTable()) { + throw new AppException("Oracle annotation은 TABLE 객체에서만 수정할 수 있습니다."); + } + } + private String requireSimpleName(String value, String label) { if (value == null || value.isBlank()) { throw new AppException(label + "은(는) 필수입니다."); @@ -243,7 +255,11 @@ public class SchemaMetadataService { } private String qualifiedTable(String tableName) { - return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name")); + return quoteName(owner()) + "." + quoteName(requireSimpleName(tableName, "table name")); + } + + private String owner() { + return structuredDataService.owner(); } private String quoteName(String value) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java new file mode 100644 index 0000000..61dabdb --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -0,0 +1,124 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties; +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** + * Read-only catalogue of security deployment SQL bundled from the Git-tracked + * database/adb directory. Script ids are an application whitelist: request input + * never becomes a filesystem or classpath path. + */ +@Service +public class SecuritySqlScriptService { + + private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + private static final Pattern RESOURCE_PATH = Pattern.compile( + "(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql" + ); + private final List scripts; + + public SecuritySqlScriptService( + SecuritySqlScriptProperties properties, + ObjectMapper objectMapper + ) { + scripts = parse(properties.scripts(), objectMapper); + } + + public List list() { + return scripts.stream() + .map(definition -> new SecuritySqlScriptSummary( + definition.scriptId(), + definition.category(), + definition.fileName(), + definition.title(), + definition.description() + )) + .toList(); + } + + public SecuritySqlScript find(String scriptId) { + ScriptDefinition definition = scripts.stream() + .filter(candidate -> candidate.scriptId().equals(scriptId)) + .findFirst() + .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); + return new SecuritySqlScript( + definition.scriptId(), + definition.category(), + definition.fileName(), + definition.title(), + definition.description(), + readSource(definition.fileName()) + ); + } + + private String readSource(String fileName) { + ClassPathResource resource = new ClassPathResource("database/adb/" + fileName); + try (InputStream input = resource.getInputStream()) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new AppException("배포된 보안 SQL 스크립트를 읽을 수 없습니다: " + fileName); + } + } + + private List parse(String raw, ObjectMapper objectMapper) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + try { + List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다."); + } + Set scriptIds = new HashSet<>(); + Set fileNames = new HashSet<>(); + parsed.forEach(definition -> { + validate(definition); + if (!scriptIds.add(definition.scriptId())) { + throw new IllegalArgumentException("중복 scriptId"); + } + if (!fileNames.add(definition.fileName())) { + throw new IllegalArgumentException("중복 fileName"); + } + }); + return List.copyOf(parsed); + } catch (Exception exception) { + throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception); + } + } + + private void validate(ScriptDefinition definition) { + if (definition == null + || definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches() + || definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches() + || blank(definition.category()) + || blank(definition.title()) + || blank(definition.description())) { + throw new IllegalArgumentException("보안 SQL 정의가 올바르지 않습니다."); + } + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } + + public record ScriptDefinition( + String scriptId, + String category, + String fileName, + String title, + String description + ) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java new file mode 100644 index 0000000..453bb4c --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -0,0 +1,351 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.Locale; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** Generates and executes bounded read-only SQL through a configured Select AI profile. */ +@Service +public class SelectAiService { + + private static final int MAX_PROMPT_LENGTH = 4_000; + private static final int MAX_RESULT_ROWS = 100; + private static final int QUERY_TIMEOUT_SECONDS = 30; + private static final Pattern UNSAFE_SQL = Pattern.compile( + "(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|" + + "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|" + + "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b" + ); + + private final BackofficeProperties properties; + private final HmmMcpBearerAuthenticator bearerAuthenticator; + private final ObjectMapper objectMapper; + + public SelectAiService( + BackofficeProperties properties, + HmmMcpBearerAuthenticator bearerAuthenticator, + ObjectMapper objectMapper + ) { + this.properties = properties; + this.bearerAuthenticator = bearerAuthenticator; + this.objectMapper = objectMapper; + } + + public JsonNode generateAndExecute(String bearerToken, String prompt) { + HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken); + String normalizedPrompt = requiredPrompt(prompt); + BackofficeProperties.SelectAi selectAi = + properties == null ? null : properties.selectAi(); + if (selectAi == null || !selectAi.configured()) { + throw new AppException("Select AI 연결 설정이 필요합니다. " + + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + + "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요."); + } + if (!selectAi.runtimeConfigured()) { + throw new AppException("VPD 런타임 연결 설정이 필요합니다. " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_URL, " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME, " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD를 확인하세요."); + } + + String generatedSql = generate( + selectAi, + vpdAwarePrompt( + normalizedPrompt, + principal, + loadQueryContract(selectAi.queryContractFile()) + ) + ); + String normalizedSql = validateReadOnlySql(generatedSql); + QueryExecution execution = executeReadOnly( + selectAi, normalizedSql, bearerToken.trim(), principal); + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", "SHOWSQL_AND_EXECUTED"); + response.put("profile", selectAi.profile()); + response.put("generatedSql", normalizedSql); + response.put("execution", "READ_ONLY_EXECUTED"); + response.put("rowCount", execution.items().size()); + response.put("truncated", execution.truncated()); + response.put("vpdEnforced", true); + response.put("scopeEmployeeCode", principal.employeeCode()); + response.set("items", execution.items()); + response.put("nextStep", execution.truncated() + ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다." + : "생성 SQL을 읽기 전용으로 실행한 결과입니다."); + return response; + } + + private String requiredPrompt(String prompt) { + String normalized = prompt == null ? "" : prompt.trim(); + if (normalized.isEmpty()) { + throw new AppException("prompt는 필수입니다."); + } + if (normalized.length() > MAX_PROMPT_LENGTH) { + throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다."); + } + return normalized; + } + + private String vpdAwarePrompt( + String prompt, + HmmMcpPrincipal principal, + String queryContract + ) { + String basePrompt = """ + Oracle SQL 생성 규칙: + - 프로필에 승인된 HMM HR 객체만 사용하세요. + - 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요. + - 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요. + - 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요. + + 사용자 질문: %s + """.formatted(principal.employeeCode(), prompt); + if (queryContract == null || queryContract.isBlank()) { + return basePrompt; + } + return basePrompt + """ + + 배포별 질의 계약(JSON): + 아래 계약 중 사용자 질문에 해당하는 항목만 적용하세요. 필수 필드, 계산, + 시간 기준, 누락 레코드 의미와 금지 fallback을 그대로 지키세요. + """ + queryContract; + } + + private String loadQueryContract(String configuredPath) { + if (configuredPath == null || configuredPath.isBlank()) { + return ""; + } + try { + Path path = Path.of(configuredPath.trim()).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path)) { + throw new AppException("Select AI 질의 계약 파일을 찾을 수 없습니다: " + path); + } + long size = Files.size(path); + if (size < 2 || size > 128_000) { + throw new AppException("Select AI 질의 계약 파일 크기가 허용 범위를 벗어났습니다."); + } + return Files.readString(path, StandardCharsets.UTF_8); + } catch (AppException exception) { + throw exception; + } catch (Exception exception) { + throw new AppException("Select AI 질의 계약 파일을 읽지 못했습니다: " + + safeMessage(exception)); + } + } + + private String generate( + BackofficeProperties.SelectAi selectAi, + String prompt + ) { + String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual"; + try (Connection connection = DriverManager.getConnection( + selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, prompt); + statement.setString(2, selectAi.profile()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next() || resultSet.getString(1) == null) { + throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다."); + } + return resultSet.getString(1); + } + } catch (AppException exception) { + throw exception; + } catch (Exception exception) { + throw new AppException("Select AI SHOWSQL 생성 실패: " + safeMessage(exception)); + } + } + + private QueryExecution executeReadOnly( + BackofficeProperties.SelectAi selectAi, + String generatedSql, + String bearerToken, + HmmMcpPrincipal principal + ) { + ArrayNode items = objectMapper.createArrayNode(); + boolean truncated = false; + try (Connection connection = DriverManager.getConnection( + selectAi.runtimeDbUrl(), + selectAi.runtimeDbUsername(), + selectAi.runtimeDbPassword())) { + verifyNonExemptRuntime(connection); + boolean contextSet = false; + try { + try (CallableStatement statement = connection.prepareCall( + "BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) { + statement.setString(1, "Bearer " + bearerToken); + statement.execute(); + contextSet = true; + } + verifyVpdContext(connection, principal); + connection.setAutoCommit(false); + connection.setReadOnly(true); + try (Statement transaction = connection.createStatement()) { + transaction.execute("SET TRANSACTION READ ONLY"); + } + try (PreparedStatement statement = connection.prepareStatement(generatedSql)) { + statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); + statement.setFetchSize(MAX_RESULT_ROWS + 1); + statement.setMaxRows(MAX_RESULT_ROWS + 1); + try (ResultSet resultSet = statement.executeQuery()) { + ResultSetMetaData metadata = resultSet.getMetaData(); + while (resultSet.next()) { + if (items.size() >= MAX_RESULT_ROWS) { + truncated = true; + break; + } + ObjectNode row = items.addObject(); + for (int columnIndex = 1; + columnIndex <= metadata.getColumnCount(); + columnIndex++) { + String column = metadata.getColumnLabel(columnIndex); + if (column == null || column.isBlank()) { + column = metadata.getColumnName(columnIndex); + } + putResultValue(row, column, resultSet, columnIndex); + } + } + } + } + } finally { + try { + connection.rollback(); + } finally { + if (contextSet) { + try (CallableStatement statement = connection.prepareCall( + "BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) { + statement.execute(); + } + } + } + } + } catch (Exception exception) { + throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception)); + } + return new QueryExecution(items, truncated); + } + + private void verifyNonExemptRuntime(Connection connection) throws Exception { + String runtimeUser; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT USER FROM dual")) { + if (!resultSet.next()) { + throw new AppException("VPD 런타임 DB 사용자를 확인할 수 없습니다."); + } + runtimeUser = resultSet.getString(1); + } + if (runtimeUser == null || "ADMIN".equals(runtimeUser.toUpperCase(Locale.ROOT))) { + throw new AppException("VPD 런타임 연결은 ADMIN을 사용할 수 없습니다."); + } + try (PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM SESSION_PRIVS WHERE PRIVILEGE = ?")) { + statement.setString(1, "EXEMPT ACCESS POLICY"); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next() || resultSet.getInt(1) != 0) { + throw new AppException( + "VPD 런타임 계정에 EXEMPT ACCESS POLICY가 있어 실행을 차단했습니다."); + } + } + } + } + + private void verifyVpdContext( + Connection connection, + HmmMcpPrincipal principal + ) throws Exception { + String sql = "SELECT SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE') FROM dual"; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + String employeeCode = + resultSet.next() ? resultSet.getString(1) : null; + if (employeeCode == null + || !employeeCode.equalsIgnoreCase(principal.employeeCode())) { + throw new AppException("Bearer Token 사용자와 VPD 세션 컨텍스트가 일치하지 않습니다."); + } + } + } + + private void putResultValue( + ObjectNode row, + String column, + ResultSet resultSet, + int columnIndex + ) throws Exception { + Object value = resultSet.getObject(columnIndex); + if (value == null) { + row.putNull(column); + } else if (value instanceof BigDecimal number) { + row.put(column, number); + } else if (value instanceof BigInteger number) { + row.put(column, number); + } else if (value instanceof Integer number) { + row.put(column, number); + } else if (value instanceof Long number) { + row.put(column, number); + } else if (value instanceof Short number) { + row.put(column, number); + } else if (value instanceof Float number) { + row.put(column, number); + } else if (value instanceof Double number) { + row.put(column, number); + } else if (value instanceof Boolean bool) { + row.put(column, bool); + } else { + // Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as + // oracle.sql.TIMESTAMPTZ@, which is not usable MCP evidence. + row.put(column, resultSet.getString(columnIndex)); + } + } + + String validateReadOnlySql(String generatedSql) { + String normalized = generatedSql == null ? "" : generatedSql.trim(); + if (normalized.startsWith("```")) { + int firstLineEnd = normalized.indexOf('\n'); + int closingFence = normalized.lastIndexOf("```"); + if (firstLineEnd >= 0 && closingFence > firstLineEnd) { + normalized = normalized.substring(firstLineEnd + 1, closingFence).trim(); + } + } + normalized = normalized.replaceFirst(";\\s*$", "").trim(); + if (!normalized.matches("(?is)^(select|with)\\b.*")) { + throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다."); + } + if (normalized.contains(";")) { + throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 실행하지 않습니다."); + } + if (normalized.contains("--") + || normalized.contains("/*") + || normalized.contains("*/") + || UNSAFE_SQL.matcher(normalized).find()) { + throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다."); + } + return normalized; + } + + private String safeMessage(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() + ? exception.getClass().getSimpleName() + : message; + } + + private record QueryExecution(ArrayNode items, boolean truncated) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java similarity index 96% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java index 888e047..2718226 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java @@ -30,7 +30,8 @@ public class SettingService { @Transactional public void updateOrdsBaseUrl(String value) { if (value == null || value.isBlank()) { - throw new AppException("ORDS Base URL은 필수입니다."); + settingMapper.deleteByKey(ORDS_BASE_URL); + return; } String normalized = normalizeUrl(value); if (!normalized.startsWith("https://") && !normalized.startsWith("http://")) { diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java new file mode 100644 index 0000000..09f121d --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -0,0 +1,101 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import java.util.List; +import java.util.Map; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +@Service +public class StructuredDataService { + + private static final int ROW_LIMIT = 50; + + private final JdbcTemplate jdbcTemplate; + private final DataCatalog catalog; + + public StructuredDataService( + JdbcTemplate jdbcTemplate, + DataCatalog catalog + ) { + this.jdbcTemplate = jdbcTemplate; + this.catalog = catalog; + } + + public DataCatalog catalog() { + return catalog; + } + + public String owner() { + return catalog.owner(); + } + + public List tables() { + return catalog.objects(); + } + + public String defaultKey() { + return catalog.objects().getFirst().key(); + } + + public StructuredDataTable requireTable(String key) { + return catalog.require(key); + } + + public StructuredDataPreview preview(String key) { + StructuredDataTable table = requireTable(key); + try { + List columns = jdbcTemplate.query( + """ + SELECT column_name + FROM all_tab_columns + WHERE owner = ? + AND table_name = ? + ORDER BY column_id + """, + (resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName()); + if (columns.isEmpty()) { + throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); + } + List previewColumns = + table.previewColumns().isEmpty() ? columns : table.previewColumns(); + if (!columns.containsAll(previewColumns)) { + throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다."); + } + List> rows = jdbcTemplate.queryForList( + previewSql(table, previewColumns), ROW_LIMIT); + return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT); + } catch (DataAccessException exception) { + throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner() + + " 조회 권한과 대상 객체 상태를 확인하세요."); + } + } + + /** + * The table is selected from a closed application whitelist, so the query + * text remains fixed and no request value can become a SQL identifier. + */ + String previewSql(StructuredDataTable table) { + return previewSql(table, table.previewColumns()); + } + + private String previewSql( + StructuredDataTable table, + List previewColumns + ) { + StructuredDataTable approved = requireTable(table.key()); + if (!approved.tableName().equals(table.tableName())) { + throw new AppException("선택할 수 없는 카탈로그 객체입니다."); + } + String projection = previewColumns == null || previewColumns.isEmpty() + ? "*" + : previewColumns.stream() + .map(column -> "\"" + column + "\"") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(); + return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName() + + "\" WHERE ROWNUM <= ?"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java similarity index 91% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java index dc5135e..98a716c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java @@ -56,9 +56,9 @@ public class VectorKnowledgeService { } public VectorKnowledgeSummary summary() { - int documents = count("SELECT COUNT(DISTINCT document_id) FROM sg_vector_document_chunk"); - int chunks = count("SELECT COUNT(*) FROM sg_vector_document_chunk"); - int tags = count("SELECT COUNT(*) FROM sg_vector_document_tag"); + int documents = count("SELECT COUNT(*) FROM hmm_knowledge_documents"); + int chunks = count("SELECT COUNT(*) FROM hmm_knowledge_chunks"); + int tags = count("SELECT COUNT(*) FROM hmm_knowledge_tags"); boolean registered = protectedObjectService.findEnabled().stream() .anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName())); return new VectorKnowledgeSummary( @@ -100,6 +100,11 @@ public class VectorKnowledgeService { List chunks = VectorChunker.chunk(content, command.chunkSize()); replaceDocument(documentId); + jdbcTemplate.update(""" + INSERT INTO hmm_knowledge_documents + (document_id, file_name, source_uri, abstract_text, original_text, updated_at) + VALUES (?, ?, ?, ?, ?, SYSTIMESTAMP) + """, documentId, title, sourceUri, title, content); long nextChunkId = nextChunkId(chunks.size()); int tagCount = 0; for (VectorChunk chunk : chunks) { @@ -107,13 +112,13 @@ public class VectorKnowledgeService { String embeddingJson = json(embedding); long chunkId = nextChunkId++; jdbcTemplate.update(""" - INSERT INTO sg_vector_document_chunk + INSERT INTO hmm_knowledge_chunks (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding) VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?)) """, chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson); for (String tag : tags) { jdbcTemplate.update(""" - INSERT INTO sg_vector_document_tag (chunk_id, tech_tag) + INSERT INTO hmm_knowledge_tags (chunk_id, tech_tag) VALUES (?, ?) """, chunkId, tag); tagCount++; @@ -143,18 +148,18 @@ public class VectorKnowledgeService { private void replaceDocument(String documentId) { jdbcTemplate.update(""" - DELETE FROM sg_vector_document_tag + DELETE FROM hmm_knowledge_tags WHERE chunk_id IN ( - SELECT chunk_id FROM sg_vector_document_chunk WHERE document_id = ? + SELECT chunk_id FROM hmm_knowledge_chunks WHERE document_id = ? ) """, documentId); - jdbcTemplate.update("DELETE FROM sg_vector_document_chunk WHERE document_id = ?", documentId); + jdbcTemplate.update("DELETE FROM hmm_knowledge_documents WHERE document_id = ?", documentId); } private long nextChunkId(int chunkCount) { try { Long sequenceValue = jdbcTemplate.queryForObject( - "SELECT sg_vector_chunk_seq.NEXTVAL FROM dual", Long.class); + "SELECT hmm_knowledge_chunk_seq.NEXTVAL FROM dual", Long.class); if (sequenceValue != null) { return sequenceValue; } @@ -163,7 +168,7 @@ public class VectorKnowledgeService { // fallback keeps the demonstration usable; the setup SQL creates it. } Long max = jdbcTemplate.queryForObject( - "SELECT NVL(MAX(chunk_id), 28000) FROM sg_vector_document_chunk", Long.class); + "SELECT NVL(MAX(chunk_id), 28000) FROM hmm_knowledge_chunks", Long.class); return (max == null ? 28000 : max) + 1; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java similarity index 94% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java index 55b26a4..47aabd5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java @@ -39,6 +39,8 @@ public class VpdPolicyService { private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L; private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY"; private static final String DEFAULT_PERMISSION_FILTER_FUNCTION = "CB_AGENT_DOC_VPD_FILTER"; + private static final String HMM_LEAVE_POLICY_NAME = "HMM_LEAVE_SCOPE_POLICY"; + private static final String HMM_LEAVE_FILTER_FUNCTION = "HMM_LEAVE_VPD_FILTER"; /* * DBMS_RLS.ADD_POLICY accepts PL/SQL BOOLEAN arguments. Keep all four * permitted flag combinations as fixed statements: database object names @@ -154,8 +156,8 @@ public class VpdPolicyService { @Transactional public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) { String functionName = requiredIdentifier(functionNameValue, "Function name"); - if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { - throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION + if (isSystemManagedPermissionFilter(functionName)) { + throw new AppException("기본 동적 권한 필터 " + functionName + "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·행 접근 규칙 화면에서 변경하세요."); } String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); @@ -180,15 +182,18 @@ public class VpdPolicyService { if (objectParts.length != 2) { throw new AppException("보호할 객체 형식이 올바르지 않습니다: " + objectKey); } - String functionKey = formOptions().defaultPermissionFunctionKey(); + String functionKey = formOptions().defaultPermissionFunctionKey(objectParts[1]); if (functionKey.isBlank()) { - throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION + throw new AppException("대상 객체에 사용할 기본 동적 권한 필터" + "가 설치되어 있지 않습니다. 운영 상태에서 동적 권한 필터 설치 여부를 확인한 뒤 다시 적용하세요."); } + String policyName = isHmmLeaveObject(objectParts[1]) + ? HMM_LEAVE_POLICY_NAME + : COMMON_POLICY_NAME; createPolicy(new VpdPolicyCreateCommand( objectParts[0], objectParts[1], - COMMON_POLICY_NAME, + policyName, functionKey, null, null, @@ -352,8 +357,8 @@ public class VpdPolicyService { description = null; } return description == null || description.isBlank() - ? (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName) - ? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다." + ? (isSystemManagedPermissionFilter(functionName) + ? defaultPermissionFilterDescription(functionName) : "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.") : description; } @@ -377,7 +382,7 @@ public class VpdPolicyService { */ public String findFilterPredicate(String owner, String packageName, String functionName) { if (packageName != null && !packageName.isBlank() - || DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { + || isSystemManagedPermissionFilter(functionName)) { return ""; } try { @@ -493,10 +498,11 @@ public class VpdPolicyService { ) { List templates = new ArrayList<>(); functions.stream() - .filter(function -> DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(function.functionName())) - .findFirst() - .ifPresent(function -> templates.add(new VpdPolicyTemplateOption( - COMMON_POLICY_NAME, + .filter(VpdFunctionOption::permissionSystemDefault) + .forEach(function -> templates.add(new VpdPolicyTemplateOption( + HMM_LEAVE_FILTER_FUNCTION.equalsIgnoreCase(function.functionName()) + ? HMM_LEAVE_POLICY_NAME + : COMMON_POLICY_NAME, function.owner(), function.packageName(), function.functionName(), @@ -746,6 +752,23 @@ public class VpdPolicyService { return formOptions().defaultPermissionFunctionKey(); } + private boolean isSystemManagedPermissionFilter(String functionName) { + return DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName) + || HMM_LEAVE_FILTER_FUNCTION.equalsIgnoreCase(functionName); + } + + private boolean isHmmLeaveObject(String objectName) { + return objectName != null + && objectName.toUpperCase(Locale.ROOT).startsWith("HMM_LEAVE_"); + } + + private String defaultPermissionFilterDescription(String functionName) { + if (HMM_LEAVE_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { + return "HMM 직원 본인·직접 보고 팀원·HR 관리자 역할을 행 조건으로 변환합니다."; + } + return "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."; + } + private String normalizeStatementTypes(String value) { String raw = value == null || value.isBlank() ? "SELECT" : value; List statements = List.of(raw.split(",")).stream() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java similarity index 94% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java index 8f00887..c72f894 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java @@ -27,7 +27,7 @@ public class DashboardController { @GetMapping("/") public String dashboard(Model model) { - // The Smilegate PoC home is an identity-administration landing page. + // The backoffice home is an identity-administration landing page. // It intentionally does not query legacy CB_* VPD catalog objects. model.addAttribute("users", userService.findAll()); model.addAttribute("groups", groupService.findAll()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/EffectiveMatrixController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java similarity index 92% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index 0b83085..3f32f95 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -1,5 +1,6 @@ package com.cloudhandson.vpdbackoffice.web; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import com.cloudhandson.vpdbackoffice.service.BearerTokenService; @@ -23,19 +24,22 @@ public class McpReasoningController { private final McpReasoningService reasoningService; private final BearerTokenService tokenService; private final UserMapper userMapper; + private final McpProperties mcpProperties; public McpReasoningController( McpToolRegistry toolRegistry, McpSseService mcpSseService, McpReasoningService reasoningService, BearerTokenService tokenService, - UserMapper userMapper + UserMapper userMapper, + McpProperties mcpProperties ) { this.toolRegistry = toolRegistry; this.mcpSseService = mcpSseService; this.reasoningService = reasoningService; this.tokenService = tokenService; this.userMapper = userMapper; + this.mcpProperties = mcpProperties; } @GetMapping("/mcp-reasoning") @@ -61,6 +65,7 @@ public class McpReasoningController { @GetMapping("/mcp-sse") public String ssePage(Model model) { model.addAttribute("tools", mcpSseService.registeredTools()); + model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl()); return "mcp-sse"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java similarity index 86% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java index e78738d..6d9b078 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.McpSseService; +import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -40,12 +41,18 @@ public class McpSseController { @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @RequestBody JsonNode request ) { - // JSON-RPC notifications never receive a response body. Current MCP - // clients send notifications/initialized immediately after initialize. - if (request != null && !request.has("id")) { - return ResponseEntity.accepted().build(); + try { + ObjectNode response = mcpSseService.handle( + "default", request, bearerToken(authorization)); + // JSON-RPC notifications never receive a response body. Current MCP + // clients send notifications/initialized immediately after initialize. + if (request != null && !request.has("id")) { + return ResponseEntity.accepted().build(); + } + return ResponseEntity.ok(response); + } catch (McpUnauthorizedException exception) { + return ResponseEntity.status(401).build(); } - return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization))); } @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -98,8 +105,13 @@ public class McpSseController { JsonNode request ) throws IOException { String normalizedContextPath = normalizeContextPath(contextPath); - ObjectNode response = mcpSseService.handle( - normalizedContextPath, request, bearerToken(authorization)); + ObjectNode response; + try { + response = mcpSseService.handle( + normalizedContextPath, request, bearerToken(authorization)); + } catch (McpUnauthorizedException exception) { + return ResponseEntity.status(401).build(); + } if (sessionId == null || sessionId.isBlank()) { return ResponseEntity.ok(response); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/OrdsHandlerController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java new file mode 100644 index 0000000..7af8a5e --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java @@ -0,0 +1,41 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.config.McpProperties; +import com.cloudhandson.vpdbackoffice.config.ProductProperties; +import com.cloudhandson.vpdbackoffice.service.DataCatalog; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +/** Supplies deployment labels to every server-rendered page. */ +@ControllerAdvice +public class ProductModelAdvice { + + private final ProductProperties product; + private final DataCatalog catalog; + private final McpProperties mcp; + + public ProductModelAdvice( + ProductProperties product, + DataCatalog catalog, + McpProperties mcp + ) { + this.product = product; + this.catalog = catalog; + this.mcp = mcp; + } + + @ModelAttribute("product") + ProductProperties product() { + return product; + } + + @ModelAttribute("catalogOwner") + String catalogOwner() { + return catalog.owner(); + } + + @ModelAttribute("mcp") + McpProperties mcp() { + return mcp; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RoleController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java similarity index 98% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java index 2c518e5..f6022bb 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java @@ -22,6 +22,7 @@ public class SchemaMetadataController { @GetMapping("/schema-metadata") public String schemaMetadata(@RequestParam(required = false) String table, Model model) { String selectedKey = table == null || table.isBlank() ? schemaMetadataService.defaultKey() : table; + model.addAttribute("catalog", schemaMetadataService.catalog()); model.addAttribute("tables", schemaMetadataService.tables()); model.addAttribute("selectedKey", selectedKey); try { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java similarity index 82% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java index c53a0d9..e5815b9 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService; import com.cloudhandson.vpdbackoffice.service.SettingService; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -15,14 +16,21 @@ public class SettingController { private final SettingService settingService; private final BackofficeSchemaService backofficeSchemaService; + private final McpProperties mcpProperties; - public SettingController(SettingService settingService, BackofficeSchemaService backofficeSchemaService) { + public SettingController( + SettingService settingService, + BackofficeSchemaService backofficeSchemaService, + McpProperties mcpProperties + ) { this.settingService = settingService; this.backofficeSchemaService = backofficeSchemaService; + this.mcpProperties = mcpProperties; } @GetMapping("/settings") public String settings(Model model) { + model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl()); try { model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl()); } catch (DataAccessException exception) { @@ -52,7 +60,9 @@ public class SettingController { RedirectAttributes redirectAttributes ) { settingService.updateOrdsBaseUrl(ordsBaseUrl); - redirectAttributes.addFlashAttribute("message", "ORDS 설정을 저장했습니다."); + redirectAttributes.addFlashAttribute("message", ordsBaseUrl == null || ordsBaseUrl.isBlank() + ? "레거시 ORDS 설정을 해제했습니다. HMM MCP 및 HR 질의에는 영향이 없습니다." + : "레거시 ORDS 설정을 저장했습니다."); return "redirect:/settings"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java similarity index 94% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java index 159095e..4486109 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java @@ -22,6 +22,7 @@ public class StructuredDataController { Model model ) { String selectedKey = table == null || table.isBlank() ? structuredDataService.defaultKey() : table; + model.addAttribute("catalog", structuredDataService.catalog()); model.addAttribute("tables", structuredDataService.tables()); model.addAttribute("selectedKey", selectedKey); try { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java similarity index 100% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java similarity index 97% rename from src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java rename to vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java index 78bb8b9..aa47a45 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java @@ -153,9 +153,13 @@ public class VpdPolicyController { } private static String defaultFilterDescription(String functionName) { - return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) - ? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다." - : "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다."; + if ("HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName)) { + return "HMM 직원 본인·직접 보고 팀원·HR 관리자 역할을 행 조건으로 변환합니다."; + } + if ("CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName)) { + return "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."; + } + return "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다."; } private static long elapsedMillis(long started, long finished) { diff --git a/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml similarity index 59% rename from src/main/resources/application.yml rename to vpd-backoffice/src/main/resources/application.yml index 5728558..6824731 100644 --- a/src/main/resources/application.yml +++ b/vpd-backoffice/src/main/resources/application.yml @@ -49,7 +49,7 @@ backoffice: token: max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365} ords: - base-url: ${BACKOFFICE_ORDS_BASE_URL:https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords} + base-url: ${BACKOFFICE_ORDS_BASE_URL:} timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s metadata-db: @@ -68,3 +68,34 @@ backoffice: oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}} oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} + select-ai: + # Profile-owner connection: SHOWSQL generation only. + db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}} + db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:${BACKOFFICE_DB_USERNAME:}} + db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:${BACKOFFICE_DB_PASSWORD:}} + profile: ${BACKOFFICE_SELECT_AI_PROFILE:} + # Non-EXEMPT execution boundary: no fallback to the profile owner is allowed. + runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:} + runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:} + runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:} + # Customer/project query semantics are external JSON, never Java constants. + query-contract-file: ${BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE:} + catalog: + owner: ${BACKOFFICE_CATALOG_OWNER:} + objects: ${BACKOFFICE_CATALOG_OBJECTS:} + product: + name: ${BACKOFFICE_PRODUCT_NAME:Data & AI Backoffice} + title: ${BACKOFFICE_PRODUCT_TITLE:Data & AI Backoffice} + data-label: ${BACKOFFICE_PRODUCT_DATA_LABEL:업무 데이터} + mcp: + public-url: ${BACKOFFICE_MCP_PUBLIC_URL:${BACKOFFICE_HMM_MCP_PUBLIC_URL:/mcp}} + server-name: ${BACKOFFICE_MCP_SERVER_NAME:data-ai-backoffice} + tool-name: ${BACKOFFICE_MCP_TOOL_NAME:oracle.select_ai.data_text2sql} + tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL} + tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다.} + prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.} + tools: ${BACKOFFICE_MCP_TOOLS:} + masking: + policies: ${BACKOFFICE_MASKING_POLICIES:} + security-sql-scripts: + scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:} diff --git a/src/main/resources/mapper/AuditMapper.xml b/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml similarity index 100% rename from src/main/resources/mapper/AuditMapper.xml rename to vpd-backoffice/src/main/resources/mapper/AuditMapper.xml diff --git a/src/main/resources/mapper/BearerTokenMapper.xml b/vpd-backoffice/src/main/resources/mapper/BearerTokenMapper.xml similarity index 76% rename from src/main/resources/mapper/BearerTokenMapper.xml rename to vpd-backoffice/src/main/resources/mapper/BearerTokenMapper.xml index a1661d4..523e981 100644 --- a/src/main/resources/mapper/BearerTokenMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/BearerTokenMapper.xml @@ -3,44 +3,47 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - INSERT INTO sg_access_bearer_token ( - key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description + INSERT INTO hmm_access_bearer_tokens ( + key_id, employee_id, key_prefix, key_hash, expires_at, revoked_at, description ) VALUES ( #{keyId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC}, @@ -53,7 +56,7 @@ - UPDATE sg_access_bearer_token + UPDATE hmm_access_bearer_tokens SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP}, description = CASE WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description diff --git a/src/main/resources/mapper/GroupMapper.xml b/vpd-backoffice/src/main/resources/mapper/GroupMapper.xml similarity index 69% rename from src/main/resources/mapper/GroupMapper.xml rename to vpd-backoffice/src/main/resources/mapper/GroupMapper.xml index d6c0000..bff534c 100644 --- a/src/main/resources/mapper/GroupMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/GroupMapper.xml @@ -8,7 +8,7 @@ group_name, description, active_yn - FROM sg_app_group + FROM hmm_access_groups ORDER BY group_code @@ -16,12 +16,12 @@ SELECT g.group_id, g.group_code, g.group_name, - u.user_id, - u.user_name AS username - FROM sg_user_group ug - JOIN sg_app_group g ON g.group_id = ug.group_id - JOIN sg_app_user u ON u.user_id = ug.user_id - ORDER BY g.group_code, u.user_name + u.employee_id AS user_id, + u.full_name AS username + FROM hmm_access_group_members ug + JOIN hmm_access_groups g ON g.group_id = ug.group_id + JOIN hmm_hr_employees u ON u.employee_id = ug.employee_id + ORDER BY g.group_code, u.full_name - INSERT INTO sg_app_group ( + INSERT INTO hmm_access_groups ( group_id, group_code, group_name, description, active_yn ) VALUES ( #{groupId,jdbcType=NUMERIC}, @@ -53,29 +53,29 @@ - UPDATE sg_app_group + UPDATE hmm_access_groups SET active_yn = #{activeYn,jdbcType=VARCHAR} WHERE group_id = #{groupId,jdbcType=NUMERIC} - INSERT INTO sg_user_group (group_id, user_id) + INSERT INTO hmm_access_group_members (group_id, employee_id) VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC}) - DELETE FROM sg_user_group + DELETE FROM hmm_access_group_members WHERE group_id = #{groupId,jdbcType=NUMERIC} - AND user_id = #{userId,jdbcType=NUMERIC} + AND employee_id = #{userId,jdbcType=NUMERIC} - INSERT INTO sg_group_role (group_id, role_id) + INSERT INTO hmm_group_access_roles (group_id, role_id) VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC}) - DELETE FROM sg_group_role + DELETE FROM hmm_group_access_roles WHERE group_id = #{groupId,jdbcType=NUMERIC} AND role_id = #{roleId,jdbcType=NUMERIC} diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml similarity index 67% rename from src/main/resources/mapper/MaskingRuleMapper.xml rename to vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml index c3d707a..6fd2bdc 100644 --- a/src/main/resources/mapper/MaskingRuleMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml @@ -4,41 +4,41 @@ - INSERT INTO sg_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn) + INSERT INTO cb_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn) VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode}, #{command.description}, 'Y') - UPDATE sg_masking_rule + UPDATE cb_masking_rule SET enabled_yn = #{enabledYn} WHERE rule_id = #{ruleId} @@ -54,10 +54,10 @@ r.rule_name, r.template_code, r.enabled_yn AS rule_enabled_yn - FROM sg_column_masking_rule link - JOIN sg_protected_column c ON c.column_id = link.column_id - JOIN sg_protected_object o ON o.object_id = c.object_id - JOIN sg_masking_rule r ON r.rule_id = link.rule_id + FROM cb_column_masking_rule link + JOIN cb_protected_column c ON c.column_id = link.column_id + JOIN cb_protected_object o ON o.object_id = c.object_id + JOIN cb_masking_rule r ON r.rule_id = link.rule_id ORDER BY o.owner, o.object_name, c.column_name @@ -68,19 +68,20 @@ --> @@ -180,15 +163,15 @@ r.rule_name, r.template_code, r.enabled_yn AS rule_enabled_yn - FROM sg_column_masking_rule link - JOIN sg_protected_column c ON c.column_id = link.column_id - JOIN sg_protected_object o ON o.object_id = c.object_id - JOIN sg_masking_rule r ON r.rule_id = link.rule_id + FROM cb_column_masking_rule link + JOIN cb_protected_column c ON c.column_id = link.column_id + JOIN cb_protected_object o ON o.object_id = c.object_id + JOIN cb_masking_rule r ON r.rule_id = link.rule_id WHERE c.column_id = #{columnId} - MERGE INTO sg_column_masking_rule dst + MERGE INTO cb_column_masking_rule dst USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src ON (dst.column_id = src.column_id) WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP @@ -197,12 +180,12 @@ - DELETE FROM sg_column_masking_rule + DELETE FROM cb_column_masking_rule WHERE column_id = #{columnId} - DELETE FROM sg_user_masking_rule + DELETE FROM cb_user_masking_rule WHERE column_id = #{columnId} @@ -217,12 +200,12 @@ r.template_code, assignment.decision, assignment.active_yn - FROM sg_user_masking_rule assignment - JOIN sg_app_user u ON u.user_id = assignment.user_id - JOIN sg_protected_column c ON c.column_id = assignment.column_id - JOIN sg_protected_object o ON o.object_id = c.object_id - JOIN sg_column_masking_rule link ON link.column_id = c.column_id - JOIN sg_masking_rule r ON r.rule_id = link.rule_id + FROM cb_user_masking_rule assignment + JOIN cb_app_user u ON u.user_id = assignment.user_id + JOIN cb_protected_column c ON c.column_id = assignment.column_id + JOIN cb_protected_object o ON o.object_id = c.object_id + JOIN cb_column_masking_rule link ON link.column_id = c.column_id + JOIN cb_masking_rule r ON r.rule_id = link.rule_id ORDER BY u.user_name, o.owner, o.object_name, c.column_name @@ -237,18 +220,18 @@ r.template_code, assignment.decision, assignment.active_yn - FROM sg_user_masking_rule assignment - JOIN sg_app_user u ON u.user_id = assignment.user_id - JOIN sg_protected_column c ON c.column_id = assignment.column_id - JOIN sg_protected_object o ON o.object_id = c.object_id - JOIN sg_column_masking_rule link ON link.column_id = c.column_id - JOIN sg_masking_rule r ON r.rule_id = link.rule_id + FROM cb_user_masking_rule assignment + JOIN cb_app_user u ON u.user_id = assignment.user_id + JOIN cb_protected_column c ON c.column_id = assignment.column_id + JOIN cb_protected_object o ON o.object_id = c.object_id + JOIN cb_column_masking_rule link ON link.column_id = c.column_id + JOIN cb_masking_rule r ON r.rule_id = link.rule_id WHERE assignment.user_id = #{userId} AND assignment.column_id = #{columnId} - MERGE INTO sg_user_masking_rule dst + MERGE INTO cb_user_masking_rule dst USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src ON (dst.user_id = src.user_id AND dst.column_id = src.column_id) WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP @@ -257,7 +240,7 @@ - DELETE FROM sg_user_masking_rule + DELETE FROM cb_user_masking_rule WHERE user_id = #{userId} AND column_id = #{columnId} diff --git a/src/main/resources/mapper/OperationStatusMapper.xml b/vpd-backoffice/src/main/resources/mapper/OperationStatusMapper.xml similarity index 100% rename from src/main/resources/mapper/OperationStatusMapper.xml rename to vpd-backoffice/src/main/resources/mapper/OperationStatusMapper.xml diff --git a/src/main/resources/mapper/OrdsMetadataMapper.xml b/vpd-backoffice/src/main/resources/mapper/OrdsMetadataMapper.xml similarity index 100% rename from src/main/resources/mapper/OrdsMetadataMapper.xml rename to vpd-backoffice/src/main/resources/mapper/OrdsMetadataMapper.xml diff --git a/src/main/resources/mapper/PermissionMapper.xml b/vpd-backoffice/src/main/resources/mapper/PermissionMapper.xml similarity index 61% rename from src/main/resources/mapper/PermissionMapper.xml rename to vpd-backoffice/src/main/resources/mapper/PermissionMapper.xml index 1fc3078..00d4e8f 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/PermissionMapper.xml @@ -7,7 +7,8 @@ role_name, description, max_sensitivity_level - FROM sg_app_role + FROM hmm_access_roles + WHERE active_yn = 'Y' ORDER BY role_name @@ -16,67 +17,111 @@ role_name, description, max_sensitivity_level - FROM sg_app_role + FROM hmm_access_roles WHERE role_id = #{roleId} - INSERT INTO sg_app_role (role_id, role_name, description, max_sensitivity_level) + INSERT INTO hmm_access_roles (role_id, role_name, description, max_sensitivity_level, active_yn) VALUES ( #{roleId,jdbcType=NUMERIC}, UPPER(#{roleName,jdbcType=VARCHAR}), #{description,jdbcType=VARCHAR}, - #{maxSensitivityLevel,jdbcType=VARCHAR} + #{maxSensitivityLevel,jdbcType=VARCHAR}, + 'Y' ) - UPDATE sg_app_role + UPDATE hmm_access_roles SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR} WHERE role_id = #{roleId,jdbcType=NUMERIC} - DELETE FROM sg_app_role + DELETE FROM hmm_access_roles WHERE role_id = #{roleId,jdbcType=NUMERIC} SELECT object_id, owner, object_name, ords_path, enabled_yn, description - FROM sg_protected_object + FROM cb_protected_object WHERE enabled_yn = 'Y' ORDER BY owner, object_name @@ -38,8 +38,8 @@ AND object_name NOT LIKE 'BIN$%' AND NOT EXISTS ( SELECT 1 - FROM sg_protected_object po - JOIN sg_permission p ON p.target_name = po.object_name + FROM cb_protected_object po + JOIN cb_permission p ON p.target_name = po.object_name WHERE po.owner = all_objects.owner AND po.object_name = all_objects.object_name AND po.enabled_yn = 'Y' @@ -64,7 +64,7 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE object_id = #{objectId} ORDER BY column_id @@ -77,7 +77,7 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE object_id IN #{objectId,jdbcType=NUMERIC} @@ -93,7 +93,7 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE column_id = #{columnId} @@ -105,21 +105,21 @@ visible_role_id, NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level, NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method - FROM sg_protected_column + FROM cb_protected_column WHERE object_id = #{objectId} AND column_name = UPPER(#{columnName,jdbcType=VARCHAR}) - INSERT INTO sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description) + INSERT INTO cb_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description) VALUES ( #{objectId}, UPPER(#{command.owner}), @@ -131,7 +131,7 @@ - INSERT INTO sg_protected_column ( + INSERT INTO cb_protected_column ( column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method ) VALUES ( @@ -140,7 +140,7 @@ - UPDATE sg_protected_column + UPDATE cb_protected_column SET sensitivity_level = #{sensitivityLevel,jdbcType=VARCHAR}, redaction_method = #{redactionMethod,jdbcType=VARCHAR}, sensitive_yn = CASE @@ -152,25 +152,25 @@ - UPDATE sg_protected_object + UPDATE cb_protected_object SET ords_path = #{ordsPath,jdbcType=VARCHAR} WHERE object_id = #{objectId,jdbcType=NUMERIC} - UPDATE sg_protected_object + UPDATE cb_protected_object SET description = #{description,jdbcType=VARCHAR} WHERE object_id = #{objectId,jdbcType=NUMERIC} - UPDATE sg_protected_object + UPDATE cb_protected_object SET enabled_yn = 'Y' WHERE object_id = #{objectId} - UPDATE sg_protected_object + UPDATE cb_protected_object SET enabled_yn = 'N' WHERE object_id = #{objectId} diff --git a/src/main/resources/mapper/SettingMapper.xml b/vpd-backoffice/src/main/resources/mapper/SettingMapper.xml similarity index 81% rename from src/main/resources/mapper/SettingMapper.xml rename to vpd-backoffice/src/main/resources/mapper/SettingMapper.xml index e32aeb1..45882b4 100644 --- a/src/main/resources/mapper/SettingMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/SettingMapper.xml @@ -4,12 +4,12 @@ - MERGE INTO sg_backoffice_setting dst + MERGE INTO cb_backoffice_setting dst USING ( SELECT #{settingKey,jdbcType=VARCHAR} setting_key, #{settingValue,jdbcType=VARCHAR} setting_value @@ -22,4 +22,9 @@ WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at) VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP) + + + DELETE FROM cb_backoffice_setting + WHERE setting_key = #{settingKey,jdbcType=VARCHAR} + diff --git a/src/main/resources/mapper/StakeholderMapper.xml b/vpd-backoffice/src/main/resources/mapper/StakeholderMapper.xml similarity index 100% rename from src/main/resources/mapper/StakeholderMapper.xml rename to vpd-backoffice/src/main/resources/mapper/StakeholderMapper.xml diff --git a/vpd-backoffice/src/main/resources/mapper/UserMapper.xml b/vpd-backoffice/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..0354b3e --- /dev/null +++ b/vpd-backoffice/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + INSERT INTO hmm_hr_employees ( + employee_id, employee_code, full_name, email, job_title, team_id, hire_date, employment_status + ) + SELECT #{userId}, + #{command.empNo}, + #{command.username}, + LOWER(#{command.empNo}) || '@example.test', + 'HMM Demo Employee', + t.team_id, + TRUNC(SYSDATE), + 'ACTIVE' + FROM hmm_org_teams t + WHERE t.team_code = #{command.deptCode} + + + + UPDATE hmm_hr_employees + SET employment_status = CASE WHEN #{activeYn} = 'Y' THEN 'ACTIVE' ELSE 'INACTIVE' END + WHERE employee_id = #{userId} + + + + INSERT INTO hmm_employee_access_roles (employee_id, role_id) + VALUES (#{userId}, #{roleId}) + + + + DELETE FROM hmm_employee_access_roles + WHERE employee_id = #{userId} + AND role_id = #{roleId} + + diff --git a/src/main/resources/mapper/VpdPolicyMapper.xml b/vpd-backoffice/src/main/resources/mapper/VpdPolicyMapper.xml similarity index 100% rename from src/main/resources/mapper/VpdPolicyMapper.xml rename to vpd-backoffice/src/main/resources/mapper/VpdPolicyMapper.xml diff --git a/src/main/resources/static/css/app.css b/vpd-backoffice/src/main/resources/static/css/app.css similarity index 100% rename from src/main/resources/static/css/app.css rename to vpd-backoffice/src/main/resources/static/css/app.css diff --git a/src/main/resources/static/js/app.js b/vpd-backoffice/src/main/resources/static/js/app.js similarity index 98% rename from src/main/resources/static/js/app.js rename to vpd-backoffice/src/main/resources/static/js/app.js index cec3b70..4c73aa7 100644 --- a/src/main/resources/static/js/app.js +++ b/vpd-backoffice/src/main/resources/static/js/app.js @@ -396,9 +396,10 @@ function syncRuleTypeHints(root = document) { const type = typeSelect.value; const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'STATIC_SQL'].includes(type); - const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type); + const columnOptional = ['ALL', 'MANAGED_TEAM', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type); const placeholderByType = { ALL: '값 불필요', + MANAGED_TEAM: '값 불필요', MY_DEPT: '값 불필요', SELF: '값 불필요', DEPT: '예: HR', @@ -417,10 +418,10 @@ function syncRuleTypeHints(root = document) { }; columnSelect.required = !columnOptional; - columnSelect.disabled = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type); + columnSelect.disabled = ['ALL', 'MANAGED_TEAM', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type); valueInput.required = valueRequired; valueInput.disabled = [ - 'ALL', 'MY_DEPT', 'SELF', 'TOKEN_SUBJECT', 'OWN_CONTRACT', + 'ALL', 'MANAGED_TEAM', 'MY_DEPT', 'SELF', 'TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER' ].includes(type); valueInput.placeholder = placeholderByType[type] || '비교 값'; @@ -708,8 +709,9 @@ function sqlLiteral(value) { function permissionRuleBusinessLabel(type, column, value) { const displayColumn = column || { + MANAGED_TEAM: 'EMPLOYEE_ID', MY_DEPT: 'DEPT_CODE', - SELF: 'OWNER_EMP_NO', + SELF: 'EMPLOYEE_ID', DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', TAG: 'TECH_TAG', @@ -724,6 +726,9 @@ function permissionRuleBusinessLabel(type, column, value) { if (type === 'ALL') { return '전체 행'; } + if (type === 'MANAGED_TEAM') { + return `본인 및 직접 보고 팀원 행 (${displayColumn})`; + } if (type === 'TOKEN_SUBJECT') { return `토큰 이해관계자 본인 행 (${displayColumn})`; } @@ -746,7 +751,7 @@ function permissionRuleBusinessLabel(type, column, value) { return `내 부서 행 (${displayColumn})`; } if (type === 'SELF') { - return `내 사번/소유자 행 (${displayColumn})`; + return `본인 직원 행 (${displayColumn})`; } if (type === 'TAG') { return `태그 조건 ${displayColumn} contains ${value || '-'}`; @@ -760,8 +765,9 @@ function collectWizardRules(root) { const type = row.querySelector('[name="ruleType"]')?.value || ''; const value = row.querySelector('[name="ruleValue"]')?.value || ''; const displayColumn = column || { + MANAGED_TEAM: 'EMPLOYEE_ID', MY_DEPT: 'DEPT_CODE', - SELF: 'OWNER_EMP_NO', + SELF: 'EMPLOYEE_ID', DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', TAG: 'TECH_TAG', @@ -785,8 +791,9 @@ function collectWizardPredicates(root) { const type = row.querySelector('[name="ruleType"]')?.value || ''; const value = row.querySelector('[name="ruleValue"]')?.value || ''; const displayColumn = column || { + MANAGED_TEAM: 'EMPLOYEE_ID', MY_DEPT: 'DEPT_CODE', - SELF: 'OWNER_EMP_NO', + SELF: 'EMPLOYEE_ID', DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', TAG: 'TECH_TAG', @@ -803,6 +810,9 @@ function collectWizardPredicates(root) { if (type === 'ALL') { return '1 = 1'; } + if (type === 'MANAGED_TEAM') { + return `${displayColumn} IN (현재 직원 및 직접 보고 팀원)`; + } if (type === 'STATIC_SQL') { return value; } @@ -810,7 +820,7 @@ function collectWizardPredicates(root) { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')`; } if (type === 'SELF') { - return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`; + return `${displayColumn} = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')`; } if (type === 'STAKEHOLDER_SELF') { return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`; diff --git a/src/main/resources/templates/dashboard.html b/vpd-backoffice/src/main/resources/templates/dashboard.html similarity index 96% rename from src/main/resources/templates/dashboard.html rename to vpd-backoffice/src/main/resources/templates/dashboard.html index 35cc881..353e201 100644 --- a/src/main/resources/templates/dashboard.html +++ b/vpd-backoffice/src/main/resources/templates/dashboard.html @@ -11,7 +11,7 @@

업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.

@@ -152,10 +152,10 @@ diff --git a/src/main/resources/templates/effective-matrix.html b/vpd-backoffice/src/main/resources/templates/effective-matrix.html similarity index 100% rename from src/main/resources/templates/effective-matrix.html rename to vpd-backoffice/src/main/resources/templates/effective-matrix.html diff --git a/src/main/resources/templates/error.html b/vpd-backoffice/src/main/resources/templates/error.html similarity index 100% rename from src/main/resources/templates/error.html rename to vpd-backoffice/src/main/resources/templates/error.html diff --git a/src/main/resources/templates/fragments/layout.html b/vpd-backoffice/src/main/resources/templates/fragments/layout.html similarity index 92% rename from src/main/resources/templates/fragments/layout.html rename to vpd-backoffice/src/main/resources/templates/fragments/layout.html index f88ee08..da84562 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/vpd-backoffice/src/main/resources/templates/fragments/layout.html @@ -5,7 +5,7 @@ - SMILEGATE DATA & AI POC + Data & AI Backoffice @@ -15,7 +15,7 @@