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