diff --git a/.env.example b/.env.example index f108310..03ac372 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,34 @@ 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 프로파일 소유 스키마의 별도 연결. MCP 도구가 AGENT_TOOL만 사용하면 비워 둘 수 있습니다. +export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" +export BACKOFFICE_SELECT_AI_DB_USERNAME="" +export BACKOFFICE_SELECT_AI_DB_PASSWORD="" +export BACKOFFICE_SELECT_AI_PROFILE="" + +# --- (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 diff --git a/deploy/hmm-backoffice.env.example b/deploy/hmm-backoffice.env.example new file mode 100644 index 0000000..76848fb --- /dev/null +++ b/deploy/hmm-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/systemd/vpd-backoffice-structured-data.conf b/deploy/systemd/vpd-backoffice-structured-data.conf index 6bfff19..9d2655f 100644 --- a/deploy/systemd/vpd-backoffice-structured-data.conf +++ b/deploy/systemd/vpd-backoffice-structured-data.conf @@ -1,2 +1,2 @@ [Service] -Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json +EnvironmentFile=/etc/vpd-backoffice.env 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..4f061b0 --- /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 +``` + +번들된 `sql/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/pom.xml b/pom.xml index 8fc9b81..eefd731 100644 --- a/pom.xml +++ b/pom.xml @@ -92,17 +92,13 @@ src/main/resources - + sql/adb sql/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/config/AppConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java index 539c791..ab9520a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 6d97e11..c980efc 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -8,8 +8,8 @@ public record BackofficeProperties( Security security, Token token, Ords ords, - Mcp mcp, - Ai ai + Ai ai, + SelectAi selectAi ) { public record Security( @@ -55,13 +55,6 @@ public record BackofficeProperties( public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) { } - /** - * Public HMM MCP endpoint used by Agent Factory and by operators who need to inspect the - * deployed tool contract. It is deliberately separate from the optional legacy ORDS URL. - */ - public record Mcp(String publicUrl) { - } - public record Ai( boolean enabled, String provider, @@ -80,4 +73,20 @@ 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 + ) { + + public boolean configured() { + return dbUrl != null && !dbUrl.isBlank() + && dbUsername != null && !dbUsername.isBlank() + && dbPassword != null && !dbPassword.isBlank() + && profile != null && !profile.isBlank(); + } + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/CatalogProperties.java new file mode 100644 index 0000000..38f20df --- /dev/null +++ b/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/MaskingProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/MaskingProperties.java new file mode 100644 index 0000000..ed31458 --- /dev/null +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java new file mode 100644 index 0000000..acf1c19 --- /dev/null +++ b/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/ProductProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/ProductProperties.java new file mode 100644 index 0000000..a311670 --- /dev/null +++ b/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/SecuritySqlScriptProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecuritySqlScriptProperties.java new file mode 100644 index 0000000..69201f3 --- /dev/null +++ b/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/masking/ManagedMaskingPolicy.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java deleted file mode 100644 index 6b1959a..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cloudhandson.vpdbackoffice.domain.masking; - -public record ManagedMaskingPolicy( - String objectName, - String policyName -) { -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java deleted file mode 100644 index 32bb946..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cloudhandson.vpdbackoffice.domain.structured; - -import java.util.List; - -public record StructuredDataCatalog( - String sourceName, - String owner, - String pageHelp, - String catalogDescription, - int rowLimit, - List tables -) { -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index a8f6e0a..446c88a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -5,13 +5,17 @@ import java.util.List; public record StructuredDataTable( String key, String tableName, + String objectType, String businessName, String description, - List previewColumns, - String maskingPolicyName + List previewColumns ) { public StructuredDataTable(String key, String tableName, String businessName, String description) { - this(key, tableName, businessName, description, List.of(), null); + this(key, tableName, "TABLE", businessName, description, List.of()); + } + + public boolean isTable() { + return "TABLE".equals(objectType); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index eece38a..7c908ee 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -4,8 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; 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.ManagedMaskingPolicy; 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; @@ -31,7 +31,7 @@ public interface MaskingRuleMapper { List findPolicyStatuses( @Param("owner") String owner, - @Param("policies") List policies + @Param("policies") List policies ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/DataCatalog.java new file mode 100644 index 0000000..b63fede --- /dev/null +++ b/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/EnvironmentDataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentDataCatalog.java new file mode 100644 index 0000000..0f07ffa --- /dev/null +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMaskingPolicyCatalog.java new file mode 100644 index 0000000..e6559ab --- /dev/null +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/EnvironmentMcpToolCatalog.java new file mode 100644 index 0000000..66caabf --- /dev/null +++ b/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/MaskingPolicyCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyCatalog.java new file mode 100644 index 0000000..d224b67 --- /dev/null +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index 8d21949..04c6b28 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -2,10 +2,8 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; 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; @@ -29,38 +27,43 @@ public class MaskingPolicySynchronizer { private final JdbcTemplate jdbcTemplate; private final MaskingRuleMapper mapper; - private final StructuredDataCatalog catalog; - private final Map managedPolicies; + private final DataCatalog dataCatalog; + private final MaskingPolicyCatalog policyCatalog; public MaskingPolicySynchronizer( JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper, - StructuredDataCatalogProvider catalogProvider + DataCatalog dataCatalog, + MaskingPolicyCatalog policyCatalog ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - this.catalog = catalogProvider.catalog(); - Map policies = new LinkedHashMap<>(); - catalog.tables().stream() - .filter(table -> table.maskingPolicyName() != null) - .forEach(table -> policies.put(table.tableName(), table.maskingPolicyName())); - this.managedPolicies = Collections.unmodifiableMap(policies); + this.dataCatalog = dataCatalog; + this.policyCatalog = policyCatalog; } public Set managedObjectNames() { - return managedPolicies.keySet(); + return policyCatalog.objectNames(); } public String owner() { - return catalog.owner(); + return dataCatalog.owner(); } public boolean isManagedObject(String objectName) { - return objectName != null && managedPolicies.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); + return policyCatalog.containsObject(objectName); } String managedPolicyName(String objectName) { - return managedPolicies.get(objectName); + return policyCatalog.targets().stream() + .filter(target -> target.objectName().equals(objectName)) + .map(MaskingPolicyTarget::policyName) + .findFirst() + .orElse(null); + } + + List managedPolicies() { + return policyCatalog.targets(); } /** @@ -73,9 +76,9 @@ public class MaskingPolicySynchronizer { public MaskingPolicySyncResult synchronize() { Map> desiredByObject = new LinkedHashMap<>(); for (ColumnMaskingRule rule : mapper.findColumnRules()) { - if (catalog.owner().equalsIgnoreCase(rule.owner()) + if (dataCatalog.owner().equalsIgnoreCase(rule.owner()) && rule.ruleEnabled() - && managedPolicies.containsKey(rule.objectName())) { + && policyCatalog.containsObject(rule.objectName())) { desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); } } @@ -85,9 +88,9 @@ public class MaskingPolicySynchronizer { int addedColumns = 0; int modifiedColumns = 0; int droppedColumns = 0; - for (Map.Entry policy : managedPolicies.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()) { @@ -147,7 +150,7 @@ public class MaskingPolicySynchronizer { SELECT enable FROM redaction_policies WHERE object_owner = ? AND object_name = ? AND policy_name = ? - """, String.class, catalog.owner(), objectName, policyName); + """, String.class, dataCatalog.owner(), objectName, policyName); return statuses.isEmpty() ? null : statuses.getFirst(); } @@ -156,7 +159,7 @@ public class MaskingPolicySynchronizer { SELECT column_name FROM redaction_columns WHERE object_owner = ? AND object_name = ? - """, String.class, catalog.owner(), objectName).stream() + """, String.class, dataCatalog.owner(), objectName).stream() .map(this::requiredColumnName) .toList(); } @@ -166,7 +169,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, catalog.owner(), objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void enablePolicy(String objectName, String policyName) { @@ -174,7 +177,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, catalog.owner(), objectName, policyName); + """, dataCatalog.owner(), objectName, policyName); } private void dropColumn(String objectName, String policyName, String columnName) { @@ -185,7 +188,7 @@ public class MaskingPolicySynchronizer { action => DBMS_REDACT.DROP_COLUMN, column_name => ? ); END; - """, catalog.owner(), objectName, policyName, columnName); + """, dataCatalog.owner(), objectName, policyName, columnName); } private void addPolicy( @@ -257,9 +260,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } return; } @@ -282,9 +285,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(actionConstant, functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } } @@ -320,7 +323,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, catalog.owner(), objectName, columnName, expressionName); + """, dataCatalog.owner(), objectName, columnName, expressionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicyTarget.java new file mode 100644 index 0000000..2f60dc3 --- /dev/null +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index 92621fd..b520f56 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -6,7 +6,6 @@ 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.MaskingTemplate; -import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; @@ -58,10 +57,10 @@ public class MaskingRuleService { /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */ public List findPolicyStatuses() { - List policies = maskingPolicySynchronizer.managedObjectNames().stream() - .map(objectName -> new ManagedMaskingPolicy( - objectName, maskingPolicySynchronizer.managedPolicyName(objectName))) - .toList(); + List policies = maskingPolicySynchronizer.managedPolicies(); + if (policies.isEmpty()) { + return List.of(); + } return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 2330ce8..7c2705b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -1,5 +1,7 @@ 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; @@ -8,45 +10,35 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; -/** Exposes the same read-only HMM HR tool contract as hmm-mcp.cloud-handson.com. */ +/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */ @Service public class McpSseService { - private static final List HMM_TOOLS = List.of( - new ToolSpec( - "resolve_hr_term", - "HMM_HR_TERM_RESOLVER", - "term", - "P_TERM", - "휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.", - "HMM HR 용어 표준화"), - new ToolSpec( - "search_hr_data", - "HMM_HR_NORMALIZED_DATA_SEARCH", - "query", - "P_QUERY", - "조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.", - "HMM HR 데이터 조회"), - new ToolSpec( - "search_hr_policy", - "HMM_HR_POLICY_SEARCH", - "query", - "P_QUERY", - "HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.", - "HMM HR 규정 검색") - ); + 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; } @@ -54,7 +46,7 @@ public class McpSseService { return handle(contextPath, request, ""); } - /** Validates the user bearer before serving discovery or executing a tool. */ + /** 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(); @@ -63,8 +55,10 @@ public class McpSseService { 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"); + 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); @@ -73,20 +67,27 @@ public class McpSseService { 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", exception.getMessage()); + error.put("message", safeMessage(exception)); response.set("error", error); } return response; } public List registeredTools() { - return HMM_TOOLS.stream() + return toolCatalog.tools().stream() .map(tool -> new McpToolView( - tool.name(), tool.description(), -1L, tool.displayName(), tool.agentToolName())) + tool.name(), + tool.description(), + -1L, + tool.label(), + tool.agentTool() ? tool.targetName() : MCP_CALL_PATH + )) .toList(); } @@ -94,8 +95,8 @@ public class McpSseService { ObjectNode result = objectMapper.createObjectNode(); result.put("protocolVersion", "2024-11-05"); ObjectNode serverInfo = objectMapper.createObjectNode(); - serverInfo.put("name", "hmm-hr-backoffice-" + contextPath); - serverInfo.put("version", "1.0.0"); + 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()); @@ -106,12 +107,12 @@ public class McpSseService { private ObjectNode toolsListResult() { ObjectNode result = objectMapper.createObjectNode(); ArrayNode tools = objectMapper.createArrayNode(); - HMM_TOOLS.forEach(tool -> tools.add(toolDefinition(tool))); + toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool))); result.set("tools", tools); return result; } - private ObjectNode toolDefinition(ToolSpec tool) { + private ObjectNode toolDefinition(McpToolDefinition tool) { ObjectNode item = objectMapper.createObjectNode(); item.put("name", tool.name()); item.put("description", tool.description()); @@ -133,23 +134,34 @@ public class McpSseService { } private ObjectNode toolsCallResult(JsonNode params, String bearerToken) { - String requestedName = params.path("name").asText(""); - ToolSpec tool = HMM_TOOLS.stream() - .filter(candidate -> candidate.name().equals(requestedName)) - .findFirst() - .orElseThrow(() -> new AppException("등록되지 않은 HMM MCP tool입니다: " + requestedName)); + 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() + " 입력값은 비워둘 수 없습니다."); } - ObjectNode input = objectMapper.createObjectNode(); - input.put(tool.agentParameterName(), argument); - JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken); + + 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("agentTool", tool.agentToolName()); + 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(); @@ -161,6 +173,11 @@ public class McpSseService { 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); @@ -168,19 +185,4 @@ public class McpSseService { return String.valueOf(value); } } - - private record ToolSpec( - String name, - String agentToolName, - String argumentName, - String agentParameterName, - String description, - String displayName - ) { - String argumentDescription() { - return "term".equals(argumentName) - ? "확인할 휴가·근태 용어, 동의어 또는 코드입니다." - : "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다."; - } - } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolCatalog.java new file mode 100644 index 0000000..2c68bbb --- /dev/null +++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolDefinition.java new file mode 100644 index 0000000..b2f1487 --- /dev/null +++ b/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/SchemaMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index cd00697..256bc8d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -3,7 +3,6 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -35,7 +34,7 @@ public class SchemaMetadataService { return structuredDataService.tables(); } - public StructuredDataCatalog catalog() { + public DataCatalog catalog() { return structuredDataService.catalog(); } @@ -47,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, @@ -77,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); } @@ -88,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); } @@ -227,6 +229,12 @@ public class SchemaMetadataService { 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 + "은(는) 필수입니다."); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java index fab300f..1550c72 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -1,11 +1,17 @@ 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; @@ -17,46 +23,21 @@ import org.springframework.stereotype.Service; @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를 노출합니다." - ) + 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 CURATED_SCRIPTS.stream() + return scripts.stream() .map(definition -> new SecuritySqlScriptSummary( definition.scriptId(), definition.category(), @@ -68,7 +49,7 @@ public class SecuritySqlScriptService { } public SecuritySqlScript find(String scriptId) { - ScriptDefinition definition = CURATED_SCRIPTS.stream() + ScriptDefinition definition = scripts.stream() .filter(candidate -> candidate.scriptId().equals(scriptId)) .findFirst() .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); @@ -91,7 +72,48 @@ public class SecuritySqlScriptService { } } - private record ScriptDefinition( + 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, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java new file mode 100644 index 0000000..e7ae37a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -0,0 +1,232 @@ +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.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.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) { + 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을 확인하세요."); + } + + String generatedSql = generate(selectAi, normalizedPrompt); + String normalizedSql = validateReadOnlySql(generatedSql); + QueryExecution execution = executeReadOnly( + selectAi, normalizedSql, bearerToken.trim()); + 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.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 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 + ) { + ArrayNode items = objectMapper.createArrayNode(); + boolean truncated = false; + try (Connection connection = DriverManager.getConnection( + selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) { + 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; + } + 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.getObject(columnIndex)); + } + } + } + } + } finally { + try { + connection.rollback(); + } finally { + if (contextSet) { + try (CallableStatement statement = connection.prepareCall( + "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + statement.execute(); + } + } + } + } + } catch (Exception exception) { + throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception)); + } + return new QueryExecution(items, truncated); + } + + private void putResultValue(ObjectNode row, String column, Object value) { + 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 { + row.put(column, String.valueOf(value)); + } + } + + 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/StructuredDataCatalogProvider.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java deleted file mode 100644 index 4d0166c..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Component; - -@Component -public class StructuredDataCatalogProvider { - - private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); - private static final Pattern TABLE_KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}"); - private static final int MAX_ROW_LIMIT = 500; - - private final StructuredDataCatalog catalog; - - public StructuredDataCatalogProvider( - ObjectMapper objectMapper, - @Value("${backoffice.structured-data.catalog-location:classpath:/config/structured-data-catalog.json}") - Resource catalogResource - ) { - try (var input = catalogResource.getInputStream()) { - this.catalog = validate(objectMapper.readValue(input, StructuredDataCatalog.class)); - } catch (IOException exception) { - throw new IllegalStateException( - "정형 데이터 카탈로그 JSON을 읽을 수 없습니다: " + catalogResource.getDescription(), exception); - } - } - - public StructuredDataCatalog catalog() { - return catalog; - } - - private StructuredDataCatalog validate(StructuredDataCatalog source) { - if (source == null) { - throw new IllegalStateException("정형 데이터 카탈로그가 비어 있습니다."); - } - String sourceName = requireText(source.sourceName(), "sourceName"); - String owner = requireOracleName(source.owner(), "owner"); - String pageHelp = requireText(source.pageHelp(), "pageHelp"); - String catalogDescription = requireText(source.catalogDescription(), "catalogDescription"); - if (source.rowLimit() < 1 || source.rowLimit() > MAX_ROW_LIMIT) { - throw new IllegalStateException("정형 데이터 카탈로그 rowLimit은 1~" + MAX_ROW_LIMIT + " 범위여야 합니다."); - } - if (source.tables() == null || source.tables().isEmpty()) { - throw new IllegalStateException("정형 데이터 카탈로그에는 테이블이 한 개 이상 필요합니다."); - } - - Set keys = new HashSet<>(); - Set tableNames = new HashSet<>(); - List tables = source.tables().stream().map(table -> { - if (table == null) { - throw new IllegalStateException("정형 데이터 카탈로그에 null 테이블 정의가 있습니다."); - } - String key = requireKey(table.key()); - String tableName = requireOracleName(table.tableName(), "tableName"); - if (!keys.add(key)) { - throw new IllegalStateException("정형 데이터 카탈로그 key가 중복됩니다: " + key); - } - if (!tableNames.add(tableName)) { - throw new IllegalStateException("정형 데이터 카탈로그 tableName이 중복됩니다: " + tableName); - } - List previewColumns = table.previewColumns() == null - ? List.of() - : table.previewColumns().stream() - .map(column -> requireOracleName(column, "previewColumns")) - .distinct() - .toList(); - String maskingPolicyName = table.maskingPolicyName() == null || table.maskingPolicyName().isBlank() - ? null - : requireOracleName(table.maskingPolicyName(), "maskingPolicyName"); - return new StructuredDataTable( - key, - tableName, - requireText(table.businessName(), "businessName"), - requireText(table.description(), "description"), - List.copyOf(previewColumns), - maskingPolicyName); - }).toList(); - - return new StructuredDataCatalog( - sourceName, owner, pageHelp, catalogDescription, source.rowLimit(), List.copyOf(tables)); - } - - private String requireKey(String value) { - String normalized = requireText(value, "key").toLowerCase(Locale.ROOT); - if (!TABLE_KEY.matcher(normalized).matches()) { - throw new IllegalStateException("정형 데이터 카탈로그 key 형식이 올바르지 않습니다: " + value); - } - return normalized; - } - - private String requireOracleName(String value, String field) { - String normalized = requireText(value, field).toUpperCase(Locale.ROOT); - if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) { - throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 형식이 올바르지 않습니다: " + value); - } - return normalized; - } - - private String requireText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 값은 필수입니다."); - } - return value.trim(); - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index d13ad92..09f121d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -1,6 +1,5 @@ package com.cloudhandson.vpdbackoffice.service; -import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.List; @@ -12,18 +11,20 @@ import org.springframework.stereotype.Service; @Service public class StructuredDataService { + private static final int ROW_LIMIT = 50; + private final JdbcTemplate jdbcTemplate; - private final StructuredDataCatalog catalog; + private final DataCatalog catalog; public StructuredDataService( JdbcTemplate jdbcTemplate, - StructuredDataCatalogProvider catalogProvider + DataCatalog catalog ) { this.jdbcTemplate = jdbcTemplate; - this.catalog = catalogProvider.catalog(); + this.catalog = catalog; } - public StructuredDataCatalog catalog() { + public DataCatalog catalog() { return catalog; } @@ -32,18 +33,15 @@ public class StructuredDataService { } public List tables() { - return catalog.tables(); + return catalog.objects(); } public String defaultKey() { - return catalog.tables().getFirst().key(); + return catalog.objects().getFirst().key(); } public StructuredDataTable requireTable(String key) { - return catalog.tables().stream() - .filter(table -> table.key().equals(key)) - .findFirst() - .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); + return catalog.require(key); } public StructuredDataPreview preview(String key) { @@ -61,17 +59,17 @@ public class StructuredDataService { if (columns.isEmpty()) { throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); } - List previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns(); + List previewColumns = + table.previewColumns().isEmpty() ? columns : table.previewColumns(); if (!columns.containsAll(previewColumns)) { - throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다."); + throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다."); } - List> rows = jdbcTemplate.queryForList( - previewSql(table, previewColumns), catalog.rowLimit()); - return new StructuredDataPreview(table, previewColumns, rows, catalog.rowLimit()); + previewSql(table, previewColumns), ROW_LIMIT); + return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT); } catch (DataAccessException exception) { - throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.sourceName() + "의 " - + catalog.owner() + " 조회 권한과 대상 테이블 상태를 확인하세요."); + throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner() + + " 조회 권한과 대상 객체 상태를 확인하세요."); } } @@ -83,15 +81,20 @@ public class StructuredDataService { return previewSql(table, table.previewColumns()); } - private String previewSql(StructuredDataTable table, List previewColumns) { + private String previewSql( + StructuredDataTable table, + List previewColumns + ) { StructuredDataTable approved = requireTable(table.key()); if (!approved.tableName().equals(table.tableName())) { - throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); + throw new AppException("선택할 수 없는 카탈로그 객체입니다."); } String projection = previewColumns == null || previewColumns.isEmpty() ? "*" - : previewColumns.stream().map(column -> "\"" + column + "\"") - .reduce((left, right) -> left + ", " + right).orElseThrow(); + : 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/web/DashboardController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java index 8f00887..c72f894 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java +++ b/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/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index efff248..3f32f95 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -1,6 +1,6 @@ package com.cloudhandson.vpdbackoffice.web; -import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +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; @@ -24,7 +24,7 @@ public class McpReasoningController { private final McpReasoningService reasoningService; private final BearerTokenService tokenService; private final UserMapper userMapper; - private final BackofficeProperties properties; + private final McpProperties mcpProperties; public McpReasoningController( McpToolRegistry toolRegistry, @@ -32,14 +32,14 @@ public class McpReasoningController { McpReasoningService reasoningService, BearerTokenService tokenService, UserMapper userMapper, - BackofficeProperties properties + McpProperties mcpProperties ) { this.toolRegistry = toolRegistry; this.mcpSseService = mcpSseService; this.reasoningService = reasoningService; this.tokenService = tokenService; this.userMapper = userMapper; - this.properties = properties; + this.mcpProperties = mcpProperties; } @GetMapping("/mcp-reasoning") @@ -65,7 +65,7 @@ public class McpReasoningController { @GetMapping("/mcp-sse") public String ssePage(Model model) { model.addAttribute("tools", mcpSseService.registeredTools()); - model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl()); + model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl()); return "mcp-sse"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProductModelAdvice.java new file mode 100644 index 0000000..7af8a5e --- /dev/null +++ b/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/SettingController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java index 2206745..e5815b9 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java @@ -2,7 +2,7 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService; import com.cloudhandson.vpdbackoffice.service.SettingService; -import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.McpProperties; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -16,21 +16,21 @@ public class SettingController { private final SettingService settingService; private final BackofficeSchemaService backofficeSchemaService; - private final BackofficeProperties properties; + private final McpProperties mcpProperties; public SettingController( SettingService settingService, BackofficeSchemaService backofficeSchemaService, - BackofficeProperties properties + McpProperties mcpProperties ) { this.settingService = settingService; this.backofficeSchemaService = backofficeSchemaService; - this.properties = properties; + this.mcpProperties = mcpProperties; } @GetMapping("/settings") public String settings(Model model) { - model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl()); + model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl()); try { model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl()); } catch (DataAccessException exception) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5769bf1..324b37b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,9 +33,6 @@ server: same-site: lax backoffice: - structured-data: - # Override with file:/... JSON to reuse the application for another company/data model. - catalog-location: ${BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION:classpath:/config/structured-data-catalog.json} security: admin-user: ${BACKOFFICE_ADMIN_USER:admin} admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin} @@ -52,8 +49,6 @@ backoffice: token: max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365} ords: - # HMM HR agent queries use DBMS_CLOUD_AI_AGENT through HMM MCP, not ORDS. - # Keep this empty unless an operator explicitly enables a legacy ORDS operation. base-url: ${BACKOFFICE_ORDS_BASE_URL:} timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s @@ -61,8 +56,6 @@ backoffice: url: ${BACKOFFICE_ORDS_DB_URL:} username: ${BACKOFFICE_ORDS_DB_USERNAME:} password: ${BACKOFFICE_ORDS_DB_PASSWORD:} - mcp: - public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp} ai: enabled: ${BACKOFFICE_AI_ENABLED:false} provider: ${BACKOFFICE_AI_PROVIDER:openai} @@ -75,3 +68,27 @@ 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: + db-url: ${BACKOFFICE_SELECT_AI_DB_URL:} + db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:} + db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:} + profile: ${BACKOFFICE_SELECT_AI_PROFILE:} + 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/config/structured-data-catalog.json b/src/main/resources/config/structured-data-catalog.json deleted file mode 100644 index 8a83dcd..0000000 --- a/src/main/resources/config/structured-data-catalog.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "sourceName": "HMMAIPOC", - "owner": "ADMIN", - "pageHelp": "HMM HR 데모의 승인된 조직·직원·휴가·근태 원장을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.", - "catalogDescription": "HMMAIPOC의 ADMIN 스키마에서 승인된 HMM HR 정형 테이블만 표시합니다.", - "rowLimit": 50, - "tables": [ - { - "key": "teams", - "tableName": "HMM_ORG_TEAMS", - "businessName": "조직 원장", - "description": "HMM HR 조직·팀 기본정보" - }, - { - "key": "employees", - "tableName": "HMM_HR_EMPLOYEES", - "businessName": "직원 원장", - "description": "직원·매니저·소속팀 정보", - "maskingPolicyName": "HMM_EMPLOYEE_PII_REDACT" - }, - { - "key": "leave-balances", - "tableName": "HMM_LEAVE_BALANCES", - "businessName": "휴가 잔여 원장", - "description": "직원별 연도·휴가 유형별 부여·사용·잔여 일수", - "maskingPolicyName": "HMM_LEAVE_BALANCE_REDACT" - }, - { - "key": "leave-requests", - "tableName": "HMM_LEAVE_REQUESTS", - "businessName": "휴가 신청 원장", - "description": "직원별 휴가 신청·승인 상태와 기간", - "maskingPolicyName": "HMM_LEAVE_REQUEST_REDACT" - }, - { - "key": "attendance-daily", - "tableName": "HMM_ATTENDANCE_DAILY", - "businessName": "일별 근태 원장", - "description": "직원별 출퇴근·근무 상태와 근무 시간", - "maskingPolicyName": "HMM_ATTENDANCE_REDACT" - }, - { - "key": "hr-terms", - "tableName": "HMM_HR_TERMS", - "businessName": "HR 표준 용어 원장", - "description": "휴가·근태 표준 코드, 명칭과 유사 표현", - "previewColumns": [ - "TERM_ID", - "TERM_CODE", - "TERM_KIND", - "CANONICAL_NAME", - "TERM_NAME", - "IS_CANONICAL", - "DESCRIPTION", - "EMBEDDED_AT" - ] - } - ] -} diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 89a084c..da84562 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -5,7 +5,7 @@ - HMM HR Access Console + Data & AI Backoffice @@ -15,7 +15,7 @@