refs #723: externalize backoffice catalogs and MCP tools

This commit is contained in:
devmrko
2026-07-23 19:41:54 +09:00
parent 1917df09a2
commit 0d9028ef13
58 changed files with 1671 additions and 531 deletions

View File

@@ -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

View File

@@ -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":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]'

View File

@@ -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

View File

@@ -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까지 근거와 함께 완료되어 있다.

10
pom.xml
View File

@@ -92,17 +92,13 @@
<resource>
<directory>src/main/resources</directory>
</resource>
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
only the curated read-only viewer set into the deployed JAR. -->
<!-- Keep reviewed SQL in sql/adb as the sole Git source. Runtime
BACKOFFICE_SECURITY_SQL_SCRIPTS remains the display allow-list. -->
<resource>
<directory>sql/adb</directory>
<targetPath>sql/adb</targetPath>
<includes>
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
<include>63_kb_aso_masking_rule_runtime.sql</include>
<include>64_kb_aso_masking_default_column_rules.sql</include>
<include>65_kb_select_ai_vpd_query_api.sql</include>
<include>66_kb_select_ai_vpd_query_ords.sql</include>
<include>**/*.sql</include>
</includes>
</resource>
</resources>

View File

@@ -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

View File

@@ -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();
}
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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) {
}

View File

@@ -1,7 +0,0 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record ManagedMaskingPolicy(
String objectName,
String policyName
) {
}

View File

@@ -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<StructuredDataTable> tables
) {
}

View File

@@ -5,13 +5,17 @@ import java.util.List;
public record StructuredDataTable(
String key,
String tableName,
String objectType,
String businessName,
String description,
List<String> previewColumns,
String maskingPolicyName
List<String> 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);
}
}

View File

@@ -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<MaskingPolicyStatus> findPolicyStatuses(
@Param("owner") String owner,
@Param("policies") List<ManagedMaskingPolicy> policies
@Param("policies") List<MaskingPolicyTarget> policies
);
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);

View File

@@ -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<StructuredDataTable> objects();
StructuredDataTable require(String key);
}

View File

@@ -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<StructuredDataTable> 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<StructuredDataTable> objects() {
return objects;
}
@Override
public StructuredDataTable require(String key) {
return objects.stream()
.filter(item -> item.key().equals(key))
.findFirst()
.orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다."));
}
private List<StructuredDataTable> parse(String raw, ObjectMapper objectMapper) {
if (raw == null || raw.isBlank()) {
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
}
try {
List<StructuredDataTable> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
if (parsed.isEmpty()) {
throw new IllegalArgumentException("카탈로그 객체가 비어 있습니다.");
}
Set<String> keys = new HashSet<>();
Set<String> objectNames = new HashSet<>();
List<StructuredDataTable> 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<String> 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();
}
}

View File

@@ -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<MaskingPolicyTarget> targets;
public EnvironmentMaskingPolicyCatalog(
MaskingProperties properties,
ObjectMapper objectMapper
) {
targets = parse(properties.policies(), objectMapper);
}
@Override
public List<MaskingPolicyTarget> targets() {
return targets;
}
private List<MaskingPolicyTarget> parse(String raw, ObjectMapper objectMapper) {
if (raw == null || raw.isBlank()) {
return List.of();
}
try {
List<MaskingPolicyTarget> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
if (parsed.isEmpty()) {
throw new IllegalArgumentException("마스킹 정책 목록이 비어 있습니다.");
}
Set<String> objectNames = new HashSet<>();
Set<String> policyNames = new HashSet<>();
List<MaskingPolicyTarget> 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;
}
}

View File

@@ -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<McpToolDefinition> tools;
public EnvironmentMcpToolCatalog(
McpProperties properties,
ObjectMapper objectMapper
) {
tools = parse(properties, objectMapper);
}
@Override
public List<McpToolDefinition> 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<McpToolDefinition> 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<McpToolDefinition> parsed =
objectMapper.readValue(raw, new TypeReference<>() {});
if (parsed.isEmpty()) {
throw new IllegalArgumentException("MCP 도구 목록이 비어 있습니다.");
}
Set<String> names = new HashSet<>();
List<McpToolDefinition> 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();
}
}

View File

@@ -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<MaskingPolicyTarget> targets();
default Set<String> 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));
}
}

View File

@@ -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<String, String> 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<String, String> 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<String> 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<MaskingPolicyTarget> managedPolicies() {
return policyCatalog.targets();
}
/**
@@ -73,9 +76,9 @@ public class MaskingPolicySynchronizer {
public MaskingPolicySyncResult synchronize() {
Map<String, List<ColumnMaskingRule>> 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<String, String> 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<ColumnMaskingRule> 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);
}
}

View File

@@ -0,0 +1,5 @@
package com.cloudhandson.vpdbackoffice.service;
/** A validated database object-to-redaction-policy mapping. */
public record MaskingPolicyTarget(String objectName, String policyName) {
}

View File

@@ -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<MaskingPolicyStatus> findPolicyStatuses() {
List<ManagedMaskingPolicy> policies = maskingPolicySynchronizer.managedObjectNames().stream()
.map(objectName -> new ManagedMaskingPolicy(
objectName, maskingPolicySynchronizer.managedPolicyName(objectName)))
.toList();
List<MaskingPolicyTarget> policies = maskingPolicySynchronizer.managedPolicies();
if (policies.isEmpty()) {
return List.of();
}
return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies);
}

View File

@@ -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<ToolSpec> 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<McpToolView> 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)
? "확인할 휴가·근태 용어, 동의어 또는 코드입니다."
: "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다.";
}
}
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.vpdbackoffice.service;
import java.util.List;
public interface McpToolCatalog {
List<McpToolDefinition> tools();
McpToolDefinition require(String name);
}

View File

@@ -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);
}
}

View File

@@ -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<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
Map<String, List<SchemaAnnotation>> annotations =
table.isTable() ? annotationsByTarget(tableName) : Map.of();
List<SchemaMetadataColumn> 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 + "은(는) 필수입니다.");

View File

@@ -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<ScriptDefinition> 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<ScriptDefinition> scripts;
public SecuritySqlScriptService(
SecuritySqlScriptProperties properties,
ObjectMapper objectMapper
) {
scripts = parse(properties.scripts(), objectMapper);
}
public List<SecuritySqlScriptSummary> 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<ScriptDefinition> parse(String raw, ObjectMapper objectMapper) {
if (raw == null || raw.isBlank()) {
return List.of();
}
try {
List<ScriptDefinition> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
if (parsed.isEmpty()) {
throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다.");
}
Set<String> scriptIds = new HashSet<>();
Set<String> 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,

View File

@@ -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) {
}
}

View File

@@ -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<String> keys = new HashSet<>();
Set<String> tableNames = new HashSet<>();
List<StructuredDataTable> 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<String> 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();
}
}

View File

@@ -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<StructuredDataTable> 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<String> previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns();
List<String> previewColumns =
table.previewColumns().isEmpty() ? columns : table.previewColumns();
if (!columns.containsAll(previewColumns)) {
throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다.");
throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다.");
}
List<Map<String, Object>> 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<String> previewColumns) {
private String previewSql(
StructuredDataTable table,
List<String> 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 <= ?";
}

View File

@@ -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());

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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:}

View File

@@ -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"
]
}
]
}

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="backoffice-can-mutate" th:content="${canMutate}">
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
<title th:text="${title == 'HMM HR Access Console' ? title : title + ' · HMM HR Access Console'}">HMM HR Access Console</title>
<title th:text="${title == (product?.pageTitle() ?: 'Data & AI Backoffice') ? title : title + ' · ' + (product?.pageTitle() ?: 'Data & AI Backoffice')}">Data &amp; AI Backoffice</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/app.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
@@ -15,7 +15,7 @@
<body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container rw-nav-top">
<a class="navbar-brand" href="/">HMM HR Access Console</a>
<a class="navbar-brand" href="/" th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data &amp; AI Backoffice</a>
<div class="rw-menu" aria-label="주요 메뉴">
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>

View File

@@ -7,7 +7,7 @@
<div class="login-brand">
<span class="login-mark">접근</span>
<div>
<h1>데이터 접근 제어 콘솔</h1>
<h1 th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data &amp; AI Backoffice</h1>
</div>
</div>

View File

@@ -108,8 +108,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
<tbody>
<tr th:each="status : ${policyStatuses}">
<td><code th:text="${status.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES</code></td>
<td><code th:text="${status.policyName()}">HMM_EMPLOYEE_PII_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
@@ -142,12 +142,12 @@
<section class="content-band">
<h2>컬럼 마스킹 규칙 등록</h2>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>HMM_EMAIL_STANDARD</code>.</p>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>EMAIL_STANDARD</code>.</p>
<form method="post" action="/masking-rules" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
규칙 코드
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="HMM_EMAIL_STANDARD">
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="EMAIL_STANDARD">
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
</label>
<label>
@@ -176,7 +176,7 @@
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="rule : ${rules}">
<td><code th:text="${rule.ruleCode()}">HMM_EMAIL_STANDARD</code></td>
<td><code th:text="${rule.ruleCode()}">EMAIL_STANDARD</code></td>
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
<td th:text="${rule.description() ?: '-'}">설명</td>
@@ -209,11 +209,11 @@
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
th:value="|${object.objectId()}:${columnName}|"
th:text="${object.displayName() + '.' + columnName}">ADMIN.HMM_HR_EMPLOYEES.EMAIL</option>
th:text="${object.displayName() + '.' + columnName}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
</optgroup>
</th:block>
</select>
<span class="form-hint">현재 JSON 카탈로그에 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>ADMIN.HMM_HR_EMPLOYEES.EMAIL</code>.</span>
<span class="form-hint">환경 설정에 관리 대상 ASO 정책이 있는 업무 데이터 객체만 표시됩니다. 예: <code>OWNER.OBJECT_NAME.COLUMN_NAME</code>.</span>
</label>
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
</form>

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>HMM HR MCP는 표준 용어 변환, HR 데이터 조회, HR 규정 PDF 검색의 세 도구 제공합니다. 모든 도구는 읽기 전용이며, 휴가·근태 표현이 모호하면 용어 변환을 먼저 사용합니다.</p>
<p>환경 설정으로 승인한 MCP 도구 제공합니다. <code>tools/list</code>의 설명과 입력 스키마를 확인한 뒤 사용자 Bearer Token으로 호출하세요.</p>
</details>
</section>
@@ -22,18 +22,18 @@
<div class="mcp-service-grid">
<div class="mcp-service-item">
<span>공개 MCP Endpoint</span>
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></strong>
<strong><code th:text="${hmmMcpPublicUrl}">https://example.com/mcp</code></strong>
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
</div>
<div class="mcp-service-item">
<span>인증</span>
<strong><code>Authorization: Bearer &lt;HMM MCP Token&gt;</code></strong>
<small>고정 MCP 서버 토큰으로 인증합니다. DB 비밀번호와 Wallet은 전달하지 않습니다.</small>
<strong><code>Authorization: Bearer &lt;사용자 토큰&gt;</code></strong>
<small>선택 사용자의 활성 토큰으로 인증합니다. DB 비밀번호와 Wallet은 전달하지 않습니다.</small>
</div>
<div class="mcp-service-item">
<span>도구 수</span>
<strong>3</strong>
<small>용어 표준화 → HR 데이터/규정 검색 순서로 사용합니다.</small>
<strong th:text="${#lists.size(tools) + '개'}">0</strong>
<small>배포 환경의 MCP 도구 allowlist가 적용됩니다.</small>
</div>
</div>
</section>
@@ -46,8 +46,8 @@
<table class="table align-middle">
<tbody>
<tr>
<th>HMM MCP</th>
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></td>
<th>MCP</th>
<td><code th:text="${hmmMcpPublicUrl}">https://example.com/mcp</code></td>
</tr>
<tr>
<th>Transport</th>
@@ -55,11 +55,11 @@
</tr>
<tr>
<th>백오피스 호환 Endpoint</th>
<td><code>/mcp</code>로그인 세션에서 같은 HMM 도구 계약을 확인합니다.</td>
<td><code>/mcp</code>같은 설정 기반 도구 계약을 제공합니다.</td>
</tr>
<tr>
<th>Auth</th>
<td><code>Authorization: Bearer &lt;HMM MCP Token&gt;</code>공개 MCP 서버의 고정 인증 토큰입니다.</td>
<td><code>Authorization: Bearer &lt;사용자 토큰&gt;</code>활성 업무 사용자 토큰입니다.</td>
</tr>
<tr>
<th>Methods</th>
@@ -79,20 +79,18 @@
<tr>
<th>Name</th>
<th>Object</th>
<th>Agent Tool</th>
<th>Instruction / parameter mapping</th>
<th>실행 대상</th>
<th>Instruction</th>
</tr>
</thead>
<tbody>
<tr th:each="tool : ${tools}">
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</td>
<td><code th:text="${tool.ordsPath()}">HMM_HR_TERM_RESOLVER</code></td>
<td><code th:text="${tool.ordsPath()}">AGENT_TOOL_OR_SELECT_AI</code></td>
<td>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted">
<code>resolve_hr_term.term</code> → 표준 용어·코드 · <code>search_hr_data.query</code> → HR 데이터 · <code>search_hr_policy.query</code> → 규정 PDF 검색
</small>
<small class="text-muted">정확한 argument 이름과 필수 여부는 <code>tools/list.inputSchema</code>를 사용합니다.</small>
</td>
</tr>
<tr th:if="${#lists.isEmpty(tools)}">
@@ -108,9 +106,9 @@
<summary>tools/call parameter 예시 보기</summary>
<h2>tools/call Arguments</h2>
<pre class="code-block">{
"term": "연차 이월"
"&lt;tools/list의 argument 이름&gt;": "&lt;조회할 자연어 질문&gt;"
}</pre>
<p class="form-hint">등록 도구는 <code>resolve_hr_term</code>, <code>search_hr_data</code>, <code>search_hr_policy</code>입니다. 표준 용어가 필요한 질문은 <code>resolve_hr_term</code> 결과를 사용해 데이터 또는 규정 검색을 이어갑니다.</p>
<p class="form-hint">도구명, 설명, argument 이름은 배포 환경에서 바뀔 수 있으므로 <code>tools/list</code> 결과를 기준으로 호출합니다.</p>
</details>
</section>
@@ -144,9 +142,9 @@
"id": 3,
"method": "tools/call",
"params": {
"name": "search_hr_policy",
"name": "&lt;tools/list의 name&gt;",
"arguments": {
"query": "연차 휴가 이월 기준과 제한을 알려줘."
"&lt;required argument&gt;": "조회할 자연어 질문"
}
}
}</pre>

View File

@@ -159,8 +159,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
<tbody>
<tr th:each="status : ${maskingPolicyStatuses}">
<td><code th:text="${status.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES</code></td>
<td><code th:text="${status.policyName()}">HMM_EMPLOYEE_PII_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>

View File

@@ -8,7 +8,7 @@
<h1>역할</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>스마일게이트 Data & AI PoC 운영 역할을 관리합니다. 역할은 사용자에게 직접 부여하거나 그룹을 통해 부여할 수 있습니다.</p>
<p><span th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data &amp; AI Backoffice</span> 운영 역할을 관리합니다. 역할은 사용자에게 직접 부여하거나 그룹을 통해 부여할 수 있습니다.</p>
</details>
</div>

View File

@@ -10,10 +10,10 @@
<details class="explanation-details">
<summary>도움말</summary>
<p>
<code th:text="${catalog.owner()}">OWNER</code>의 승인된 업무 테이블 table/column comment와 Oracle annotation을 조회·수정합니다.
<code th:text="${catalogOwner}">OWNER</code>의 승인된 업무 TABLE/VIEW comment와 컬럼 comment를 조회·수정합니다.
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
</p>
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, JSON 카탈로그가 승인한 업무 테이블만 대상으로 합니다.</p>
<p class="mb-0">Oracle annotation은 TABLE에서만 관리합니다. 임의 스키마나 임의 객체는 수정하지 않니다.</p>
</details>
</section>
@@ -24,7 +24,7 @@
<div class="section-heading">
<div>
<h2>테이블 선택</h2>
<p class="section-subtitle" th:text="${catalog.catalogDescription()}">승인된 업무 원장만 표시합니다.</p>
<p class="section-subtitle"><span th:text="${product.dataName()}">업무 데이터</span> 카탈로그에 등록된 TABLE/VIEW만 표시합니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">6</span>
</div>
@@ -34,8 +34,8 @@
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/schema-metadata(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">직원 원장</strong>
<code th:text="${entry.tableName()}">HMM_HR_EMPLOYEES</code>
<small th:text="${entry.description()}">직원·조직 정보</small>
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
<small><span th:text="${entry.objectType()}">TABLE</span> · <span th:text="${entry.description()}">설명</span></small>
</a>
</div>
</section>
@@ -43,10 +43,10 @@
<section class="content-band" th:if="${metadata}">
<div class="section-heading">
<div>
<span class="badge text-bg-secondary">TABLE</span>
<span class="badge text-bg-secondary" th:text="${metadata.table().objectType()}">TABLE</span>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">직원 원장</h2>
<p class="section-subtitle">
<code th:text="${catalog.owner() + '.' + metadata.table().tableName()}">OWNER.TABLE_NAME</code>
<code th:text="${catalogOwner + '.' + metadata.table().tableName()}">OWNER.OBJECT_NAME</code>
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
</p>
</div>
@@ -63,13 +63,13 @@
<button class="btn rw-btn-primary mt-2" type="submit">테이블 comment 저장</button>
</form>
<div class="section-heading">
<div class="section-heading" th:if="${metadata.table().isTable()}">
<div>
<h3>Table annotations</h3>
<p class="section-subtitle">기존 annotation은 값 수정 또는 빈 값 저장으로 삭제할 수 있습니다.</p>
</div>
</div>
<div class="table-responsive">
<div class="table-responsive" th:if="${metadata.table().isTable()}">
<table class="table table-sm align-middle">
<thead><tr><th style="width: 220px;">Annotation</th><th>Value</th><th style="width: 110px;"></th></tr></thead>
<tbody>
@@ -146,7 +146,7 @@
<button class="btn btn-sm rw-btn-primary mt-2" type="submit">컬럼 comment 저장</button>
</form>
<div class="table-responsive mt-3">
<div class="table-responsive mt-3" th:if="${metadata.table().isTable()}">
<table class="table table-sm align-middle">
<thead><tr><th style="width: 220px;">Annotation</th><th>Value</th><th style="width: 110px;"></th></tr></thead>
<tbody>

View File

@@ -8,7 +8,7 @@
<h1>시스템 설정</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>HMM HR 질의와 Agent Factory는 HMM MCP를 사용합니다. ORDS는 기존 VPD/ORDS 운영 기능이 필요한 경우에만 별도로 설정합니다.</p>
<p><span th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data &amp; AI Backoffice</span>의 MCP 공개 주소와 선택형 레거시 ORDS 연결을 확인합니다.</p>
</details>
</div>
@@ -22,9 +22,9 @@
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">HMM MCP · 현재 사용</span>
<h2>HMM HR Agent 도구</h2>
<p class="section-subtitle">용어 정규화, HR 데이터 조회, HR 정책 문서 검색은 이 MCP의 DBMS_CLOUD_AI_AGENT 도구를 사용합니다.</p>
<span class="architecture-kicker">MCP · 현재 사용</span>
<h2>설정 기반 MCP 도구</h2>
<p class="section-subtitle">환경에서 승인한 도구 계약과 사용자 Bearer Token 인증을 사용합니다.</p>
</div>
</div>
<label class="span-2">
@@ -39,7 +39,7 @@
<div>
<span class="architecture-kicker">선택형 레거시 연동</span>
<h2>ORDS Base URL</h2>
<p class="section-subtitle">기존 VPD/ORDS 접근 검증과 ORDS Handler 운영에만 사용합니다. 비워 두면 해당 기능은 미설정 상태로 표시되며 HMM HR 질의에는 영향이 없습니다.</p>
<p class="section-subtitle">기존 VPD/ORDS 접근 검증과 ORDS Handler 운영에만 사용합니다. 비워 두면 해당 기능은 미설정 상태로 표시니다.</p>
</div>
</div>
<form method="post" action="/settings/ords" class="form-grid">

View File

@@ -8,8 +8,8 @@
<h1>정형 데이터 조회</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p th:text="${catalog.pageHelp()}">승인된 업무 원장만 읽기 전용으로 조회합니다.</p>
<p class="mb-0" th:text="${'한 번에 최대 ' + catalog.rowLimit() + '건까지만 표시합니다.'}">한 번에 최대 50건까지만 표시합니다.</p>
<p>환경 설정에 등록된 <span th:text="${product.dataName()}">업무 데이터</span> 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.</p>
<p class="mb-0">한 번에 최대 50건까지만 표시합니다.</p>
</details>
</div>
@@ -20,8 +20,8 @@
<section class="content-band">
<div class="section-heading">
<div>
<h2>조회할 원장 선택</h2>
<p class="section-subtitle" th:text="${catalog.catalogDescription()}">승인된 정형 테이블만 표시합니다.</p>
<h2>조회할 업무 데이터 선택</h2>
<p class="section-subtitle"><code th:text="${catalogOwner}">OWNER</code> 스키마에서 환경 설정으로 승인한 TABLE/VIEW만 표시합니다.</p>
</div>
</div>
<div class="structured-table-grid">
@@ -30,8 +30,8 @@
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/structured-data(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">직원 원장</strong>
<code th:text="${entry.tableName()}">HMM_HR_EMPLOYEES</code>
<small th:text="${entry.description()}">직원·조직 정보</small>
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
<small><span th:text="${entry.objectType()}">TABLE</span> · <span th:text="${entry.description()}">업무 데이터</span></small>
</a>
</div>
</section>
@@ -43,7 +43,7 @@
<div>
<h2 th:text="${preview.table().businessName()}">직원 원장</h2>
<p class="section-subtitle">
<code th:text="${catalog.owner() + '.' + preview.table().tableName()}">OWNER.TABLE_NAME</code>
<code th:text="${catalogOwner + '.' + preview.table().tableName()}">OWNER.OBJECT_NAME</code>
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
</p>
</div>

View File

@@ -26,7 +26,7 @@
<select class="form-select" name="userId" required>
<option value="">선택하세요</option>
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
th:text="${user.username() + ' (' + user.empNo() + ')'}">KB_VPD_ADMIN</option>
th:text="${user.username() + ' (' + user.empNo() + ')'}">demo-user</option>
</select>
</label>
<label>
@@ -39,7 +39,7 @@
data-result=${columnRule.template().previewResult()},
data-aso-function=${columnRule.template().asoFunction()},
data-context=${'MR_' + columnRule.columnId()}"
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">KB_CUSTOMERS.RRN_MASKED</option>
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
</select>
</label>
<div>
@@ -85,8 +85,8 @@
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="userRule : ${userRules}">
<td th:text="${userRule.username()}">KB_VPD_ADMIN</td>
<td><code th:text="${userRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
<td th:text="${userRule.username()}">demo-user</td>
<td><code th:text="${userRule.targetLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</code></td>
<td th:text="${userRule.ruleLabel()}">주민번호 기본 마스킹 · 주민등록번호 부분 마스킹</td>
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
<td th:text="${userRule.activeYn()}">Y</td>

View File

@@ -8,7 +8,7 @@
<h1>사용자</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>스마일게이트 Data & AI PoC 도구를 사용할 운영 사용자를 관리합니다. 게임 서비스 이용자 Oracle DB 계정과는 별개의 백오피스 관리 대상입니다.</p>
<p><span th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data &amp; AI Backoffice</span>에서 사용할 운영 사용자를 관리합니다. 업무 시스템 사용자 Oracle DB 계정 별개로 관리됩니다.</p>
</details>
</div>

View File

@@ -82,8 +82,8 @@
</thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">POC_2.KB_CONTRACTS</code></td>
<td><code th:text="${policy.policyName()}">KB_KB_CONTRACTS_ROW_POLICY</code></td>
<td><code th:text="${policy.objectDisplayName()}">OWNER.OBJECT_NAME</code></td>
<td><code th:text="${policy.policyName()}">ROW_ACCESS_POLICY</code></td>
<td th:text="${policy.statementTypes()}">SELECT</td>
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>

View File

@@ -3,10 +3,11 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
class MaskingPolicySynchronizerTest {
@@ -28,11 +29,24 @@ class MaskingPolicySynchronizerTest {
}
@Test
void hmmEmployeePolicyComesFromTheSharedJsonCatalog() {
var catalogProvider = new StructuredDataCatalogProvider(
new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json"));
void hmmEmployeePolicyComesFromEnvironmentPolicyCatalog() {
ObjectMapper objectMapper = new ObjectMapper();
var dataCatalog = new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", """
[{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
"businessName":"직원","description":"직원"}]
"""),
objectMapper);
var policyCatalog = new EnvironmentMaskingPolicyCatalog(
new MaskingProperties("""
[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"}]
"""),
objectMapper);
var synchronizer = new MaskingPolicySynchronizer(
mock(JdbcTemplate.class), mock(MaskingRuleMapper.class), catalogProvider);
mock(JdbcTemplate.class),
mock(MaskingRuleMapper.class),
dataCatalog,
policyCatalog);
assertThat(synchronizer.owner()).isEqualTo("ADMIN");
assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES"))

View File

@@ -2,7 +2,10 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -10,16 +13,70 @@ import org.junit.jupiter.api.Test;
class McpSseServiceTest {
private static final String HMM_TOOLS = """
[
{
"name":"resolve_hr_term",
"label":"HMM HR 용어 표준화",
"description":"휴가·근태 표현을 표준 용어와 코드로 변환합니다.",
"argumentName":"term",
"argumentDescription":"확인할 휴가·근태 용어입니다.",
"executionType":"AGENT_TOOL",
"targetName":"HMM_HR_TERM_RESOLVER",
"targetParameterName":"P_TERM"
},
{
"name":"search_hr_data",
"label":"HMM HR 데이터 조회",
"description":"조직, 직원, 휴가, 근태 데이터를 조회합니다.",
"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를 검색합니다.",
"argumentName":"query",
"argumentDescription":"정책에 대한 자연어 질문입니다.",
"executionType":"AGENT_TOOL",
"targetName":"HMM_HR_POLICY_SEARCH",
"targetParameterName":"P_QUERY"
}
]
""";
private final ObjectMapper objectMapper = new ObjectMapper();
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
private final McpProperties mcpProperties =
new McpProperties(
"https://example.com/mcp",
"hmm-hr-backoffice",
"",
"",
"",
"",
HMM_TOOLS);
private final McpToolCatalog toolCatalog =
new EnvironmentMcpToolCatalog(mcpProperties, objectMapper);
private final CapturingHmmAiAgentToolRunner agentToolRunner =
new CapturingHmmAiAgentToolRunner();
private final HmmMcpBearerAuthenticator bearerAuthenticator =
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
private final McpSseService service =
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
private final McpSseService service = new McpSseService(
agentToolRunner,
mock(SelectAiService.class),
bearerAuthenticator,
toolCatalog,
mcpProperties,
new BackofficeProperties(null, null, null, null, null),
objectMapper);
@Test
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
void listsEnvironmentConfiguredTermDataAndPolicyTools() {
ObjectNode response =
service.handle("default", request(1, "tools/list"), "valid-token");
var tools = response.path("result").path("tools");
assertThat(tools).hasSize(3);
@@ -31,13 +88,10 @@ class McpSseServiceTest {
assertThat(tools.get(1).path("inputSchema").path("required"))
.extracting(JsonNode::asText)
.containsExactly("query");
assertThat(tools.get(2).path("inputSchema").path("required"))
.extracting(JsonNode::asText)
.containsExactly("query");
}
@Test
void callsHMMTermResolverWithTheApprovedAgentToolAndInputName() {
void callsConfiguredAgentToolWithItsDeclaredInputName() {
ObjectNode request = request(2, "tools/call");
ObjectNode params = request.putObject("params");
params.put("name", "resolve_hr_term");
@@ -50,19 +104,19 @@ class McpSseServiceTest {
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("resolve_hr_term")
.contains("HMM_HR_TERM_RESOLVER")
.contains("ANNUAL_LEAVE_CARRYOVER");
}
@Test
void rejectsDiscoveryWhenBearerAuthenticationFails() {
McpSseService rejectingService = new McpSseService(
agentToolRunner,
mock(SelectAiService.class),
token -> {
throw new McpUnauthorizedException();
},
toolCatalog,
mcpProperties,
new BackofficeProperties(null, null, null, null, null),
objectMapper);
assertThatThrownBy(() ->
@@ -71,16 +125,38 @@ class McpSseServiceTest {
}
@Test
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
void rejectsUnknownToolsWithoutCallingTheRunner() {
ObjectNode request = request(3, "tools/call");
ObjectNode params = request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "legacy query");
params.put("name", "unconfigured.tool");
params.putObject("arguments").put("prompt", "query");
ObjectNode response = service.handle("default", request, "valid-token");
assertThat(response.path("result").isMissingNode()).isTrue();
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
assertThat(response.path("error").path("message").asText())
.contains("등록되지 않은 MCP tool");
}
@Test
void rejectsDuplicateToolNamesAtStartup() {
String duplicate = """
[
{"name":"same","label":"A","description":"A","argumentName":"query",
"argumentDescription":"A","executionType":"AGENT_TOOL",
"targetName":"TOOL_A","targetParameterName":"P_QUERY"},
{"name":"same","label":"B","description":"B","argumentName":"query",
"argumentDescription":"B","executionType":"AGENT_TOOL",
"targetName":"TOOL_B","targetParameterName":"P_QUERY"}
]
""";
McpProperties duplicateProperties =
new McpProperties("", "", "", "", "", "", duplicate);
assertThatThrownBy(() ->
new EnvironmentMcpToolCatalog(duplicateProperties, objectMapper))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("BACKOFFICE_MCP_TOOLS");
}
private ObjectNode request(int id, String method) {
@@ -91,7 +167,8 @@ class McpSseServiceTest {
return request;
}
private final class CapturingHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
private final class CapturingHmmAiAgentToolRunner
implements HmmAiAgentToolRunner {
private String toolName;
private ObjectNode input;
@@ -106,9 +183,7 @@ class McpSseServiceTest {
toolName = requestedToolName;
input = requestedInput.deepCopy();
this.bearerToken = bearerToken;
return objectMapper.createObjectNode()
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
.put("termName", "연차 이월");
return objectMapper.createObjectNode().put("status", "ok");
}
}
}

View File

@@ -0,0 +1,34 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class SchemaMetadataServiceTest {
@Test
void rejectsAnnotationChangesForConfiguredViewsBeforeAnyDdl() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
DataCatalog catalog = new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", """
[{"key":"employee-view","tableName":"HMM_EMPLOYEE_VIEW","objectType":"VIEW",
"businessName":"직원 뷰","description":"읽기 전용 직원 뷰"}]
"""),
new ObjectMapper());
StructuredDataService structuredDataService =
new StructuredDataService(jdbcTemplate, catalog);
SchemaMetadataService service =
new SchemaMetadataService(jdbcTemplate, structuredDataService);
assertThatThrownBy(() ->
service.updateTableAnnotation("employee-view", "DISPLAY_NAME", "직원"))
.isInstanceOf(AppException.class)
.hasMessageContaining("TABLE 객체에서만");
verifyNoInteractions(jdbcTemplate);
}
}

View File

@@ -3,33 +3,51 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class SecuritySqlScriptServiceTest {
private final SecuritySqlScriptService service = new SecuritySqlScriptService();
private final SecuritySqlScriptService service = new SecuritySqlScriptService(
new SecuritySqlScriptProperties("""
[{
"scriptId":"hmm-leave-vpd",
"category":"HMM / VPD",
"fileName":"72_hmm_leave_team_vpd.sql",
"title":"HMM 휴가 팀 접근 정책",
"description":"팀장과 팀원 휴가 행 접근 정책"
}]
"""),
new ObjectMapper());
@Test
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
void exposesOnlyEnvironmentAllowlistedBundledScripts() {
assertThat(service.list())
.extracting(item -> item.fileName())
.containsExactly(
"62_kb_aso_masking_backoffice_metadata.sql",
"63_kb_aso_masking_rule_runtime.sql",
"64_kb_aso_masking_default_column_rules.sql",
"65_kb_select_ai_vpd_query_api.sql",
"66_kb_select_ai_vpd_query_ords.sql"
);
.containsExactly("72_hmm_leave_team_vpd.sql");
assertThat(service.find("select-ai-vpd-ords").source())
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
.contains("Authorization: Bearer <VPD token>");
assertThat(service.find("hmm-leave-vpd").source())
.contains("HMM_ACCESS_CTX_PKG")
.contains("HMM_LEAVE_VPD_FILTER");
}
@Test
void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() {
void rejectsUnknownScriptIdsInsteadOfResolvingARequestPath() {
assertThatThrownBy(() -> service.find("../../etc/passwd"))
.isInstanceOf(AppException.class)
.hasMessageContaining("조회할 수 없는");
}
@Test
void rejectsUnsafeResourcePathsAtStartup() {
assertThatThrownBy(() -> new SecuritySqlScriptService(
new SecuritySqlScriptProperties("""
[{"scriptId":"bad","category":"x","fileName":"../../etc/passwd.sql",
"title":"x","description":"x"}]
"""),
new ObjectMapper()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("BACKOFFICE_SECURITY_SQL_SCRIPTS");
}
}

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class SelectAiServiceTest {
private final SelectAiService service = new SelectAiService(
new BackofficeProperties(null, null, null, null, null),
token -> new HmmMcpPrincipal(1L, "E1001", 1L),
new ObjectMapper());
@Test
void acceptsOneReadOnlySelectAndRemovesTrailingSemicolon() {
assertThat(service.validateReadOnlySql(
"SELECT employee_code FROM hmm_hr_employees;"))
.isEqualTo("SELECT employee_code FROM hmm_hr_employees");
}
@Test
void rejectsDmlPackagesCommentsAndMultipleStatements() {
assertThatThrownBy(() ->
service.validateReadOnlySql("SELECT * FROM x; DELETE FROM x"))
.isInstanceOf(AppException.class);
assertThatThrownBy(() ->
service.validateReadOnlySql("SELECT DBMS_LOCK.SLEEP(10) FROM dual"))
.isInstanceOf(AppException.class);
assertThatThrownBy(() ->
service.validateReadOnlySql("SELECT * FROM x -- bypass"))
.isInstanceOf(AppException.class);
}
}

View File

@@ -4,61 +4,72 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
class StructuredDataServiceTest {
private final StructuredDataCatalogProvider catalogProvider = new StructuredDataCatalogProvider(
new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json"));
private final StructuredDataService service = new StructuredDataService(
mock(JdbcTemplate.class), catalogProvider);
private static final String HMM_OBJECTS = """
[
{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE",
"businessName":"조직 원장","description":"조직 정보"},
{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
"businessName":"직원 원장","description":"직원 정보"},
{"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"VIEW",
"businessName":"휴가 잔여 원장","description":"휴가 잔여 정보"}
]
""";
private final DataCatalog catalog = new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", HMM_OBJECTS), new ObjectMapper());
private final StructuredDataService service =
new StructuredDataService(mock(JdbcTemplate.class), catalog);
@Test
void exposesOnlyTheHMMTablesFromTheJsonCatalog() {
assertThat(service.catalog().sourceName()).isEqualTo("HMMAIPOC");
void exposesOnlyEnvironmentConfiguredTablesAndViews() {
assertThat(service.owner()).isEqualTo("ADMIN");
assertThat(service.tables())
.extracting(table -> table.tableName())
.containsExactly(
"HMM_ORG_TEAMS",
"HMM_HR_EMPLOYEES",
"HMM_LEAVE_BALANCES",
"HMM_LEAVE_REQUESTS",
"HMM_ATTENDANCE_DAILY",
"HMM_HR_TERMS");
"HMM_LEAVE_BALANCES");
assertThat(service.requireTable("leave-balances").objectType()).isEqualTo("VIEW");
assertThat(service.previewSql(service.requireTable("employees")))
.isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?");
}
@Test
void rejectsAnyTableOutsideTheServerSideAllowlist() {
void rejectsAnyObjectOutsideTheServerSideAllowlist() {
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
.isInstanceOf(AppException.class)
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
}
@Test
void rejectsUnsafeOracleIdentifiersInAnExternalCatalog() {
String unsafeJson = """
{
"sourceName": "demo",
"owner": "ADMIN; DROP USER X",
"pageHelp": "help",
"catalogDescription": "description",
"rowLimit": 10,
"tables": [
{"key":"employees","tableName":"EMPLOYEES","businessName":"직원","description":"직원"}
]
}
void rejectsUnsafeOracleIdentifiers() {
assertThatThrownBy(() -> new EnvironmentDataCatalog(
new CatalogProperties("ADMIN; DROP USER X", HMM_OBJECTS), new ObjectMapper()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("BACKOFFICE_CATALOG_OWNER");
}
@Test
void rejectsDuplicateObjectNamesEvenWhenKeysDiffer() {
String duplicate = """
[
{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
"businessName":"직원","description":"직원"},
{"key":"workers","tableName":"HMM_HR_EMPLOYEES","objectType":"VIEW",
"businessName":"직원 뷰","description":"직원 뷰"}
]
""";
assertThatThrownBy(() -> new StructuredDataCatalogProvider(
new ObjectMapper(), new ByteArrayResource(unsafeJson.getBytes())))
assertThatThrownBy(() -> new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", duplicate), new ObjectMapper()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("owner 형식이 올바르지 않습니다");
.hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS");
}
}

View File

@@ -35,7 +35,7 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("HMM HR Access Console")
.contains("Data &amp; AI Backoffice")
.contains("사용자")
.contains("접근 그룹")
.contains("역할")
@@ -73,7 +73,7 @@ class GuidedFlowTemplateTest {
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
assertThat(template("login.html"))
.contains("name=\"remember-me\"")
.contains("데이터 접근 제어 콘솔")
.contains("product?.displayName()")
.contains("<span class=\"login-mark\">접근</span>")
.contains("로그인 유지")
.contains("th:if=\"${rememberMeAvailable}\"")
@@ -265,11 +265,14 @@ class GuidedFlowTemplateTest {
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
assertThat(sse)
.contains("Instruction / parameter mapping")
.contains("resolve_hr_term")
.contains("search_hr_data")
.contains("search_hr_policy")
.doesNotContain("kb_select_ai_vpd", "KB_CLAIMS");
.contains("tools/list.inputSchema")
.contains("환경 설정으로 승인한 MCP 도구")
.doesNotContain(
"resolve_hr_term",
"search_hr_data",
"search_hr_policy",
"kb_select_ai_vpd",
"KB_CLAIMS");
}
@Test

View File

@@ -30,7 +30,7 @@ class SettingsTemplateRenderTest {
String database = engine.process("settings-database", context);
assertThat(connection)
.contains("HMM HR Agent 도구")
.contains("설정 기반 MCP 도구")
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
.contains("Legacy ORDS Base URL")
.contains("/settings/database")