refs #723: externalize backoffice catalogs and MCP tools
This commit is contained in:
29
.env.example
29
.env.example
@@ -52,7 +52,34 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
|||||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
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_ENABLED="false"
|
||||||
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||||
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
||||||
|
|||||||
18
deploy/hmm-backoffice.env.example
Normal file
18
deploy/hmm-backoffice.env.example
Normal 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":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]'
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
[Service]
|
[Service]
|
||||||
Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json
|
EnvironmentFile=/etc/vpd-backoffice.env
|
||||||
|
|||||||
255
docs/design/723-hmm-configurable-backoffice/README.md
Normal file
255
docs/design/723-hmm-configurable-backoffice/README.md
Normal 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
10
pom.xml
@@ -92,17 +92,13 @@
|
|||||||
<resource>
|
<resource>
|
||||||
<directory>src/main/resources</directory>
|
<directory>src/main/resources</directory>
|
||||||
</resource>
|
</resource>
|
||||||
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
|
<!-- Keep reviewed SQL in sql/adb as the sole Git source. Runtime
|
||||||
only the curated read-only viewer set into the deployed JAR. -->
|
BACKOFFICE_SECURITY_SQL_SCRIPTS remains the display allow-list. -->
|
||||||
<resource>
|
<resource>
|
||||||
<directory>sql/adb</directory>
|
<directory>sql/adb</directory>
|
||||||
<targetPath>sql/adb</targetPath>
|
<targetPath>sql/adb</targetPath>
|
||||||
<includes>
|
<includes>
|
||||||
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
|
<include>**/*.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>
|
|
||||||
</includes>
|
</includes>
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
@EnableConfigurationProperties({
|
||||||
|
BackofficeProperties.class,
|
||||||
|
CatalogProperties.class,
|
||||||
|
MaskingProperties.class,
|
||||||
|
McpProperties.class,
|
||||||
|
ProductProperties.class,
|
||||||
|
SecuritySqlScriptProperties.class
|
||||||
|
})
|
||||||
public class AppConfig {
|
public class AppConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ public record BackofficeProperties(
|
|||||||
Security security,
|
Security security,
|
||||||
Token token,
|
Token token,
|
||||||
Ords ords,
|
Ords ords,
|
||||||
Mcp mcp,
|
Ai ai,
|
||||||
Ai ai
|
SelectAi selectAi
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public record Security(
|
public record Security(
|
||||||
@@ -55,13 +55,6 @@ public record BackofficeProperties(
|
|||||||
public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) {
|
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(
|
public record Ai(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
String provider,
|
String provider,
|
||||||
@@ -80,4 +73,20 @@ public record BackofficeProperties(
|
|||||||
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.domain.masking;
|
|
||||||
|
|
||||||
public record ManagedMaskingPolicy(
|
|
||||||
String objectName,
|
|
||||||
String policyName
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,17 @@ import java.util.List;
|
|||||||
public record StructuredDataTable(
|
public record StructuredDataTable(
|
||||||
String key,
|
String key,
|
||||||
String tableName,
|
String tableName,
|
||||||
|
String objectType,
|
||||||
String businessName,
|
String businessName,
|
||||||
String description,
|
String description,
|
||||||
List<String> previewColumns,
|
List<String> previewColumns
|
||||||
String maskingPolicyName
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
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.domain.masking.UserMaskingRule;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
@@ -31,7 +31,7 @@ public interface MaskingRuleMapper {
|
|||||||
|
|
||||||
List<MaskingPolicyStatus> findPolicyStatuses(
|
List<MaskingPolicyStatus> findPolicyStatuses(
|
||||||
@Param("owner") String owner,
|
@Param("owner") String owner,
|
||||||
@Param("policies") List<ManagedMaskingPolicy> policies
|
@Param("policies") List<MaskingPolicyTarget> policies
|
||||||
);
|
);
|
||||||
|
|
||||||
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,8 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog;
|
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -29,38 +27,43 @@ public class MaskingPolicySynchronizer {
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final MaskingRuleMapper mapper;
|
private final MaskingRuleMapper mapper;
|
||||||
private final StructuredDataCatalog catalog;
|
private final DataCatalog dataCatalog;
|
||||||
private final Map<String, String> managedPolicies;
|
private final MaskingPolicyCatalog policyCatalog;
|
||||||
|
|
||||||
public MaskingPolicySynchronizer(
|
public MaskingPolicySynchronizer(
|
||||||
JdbcTemplate jdbcTemplate,
|
JdbcTemplate jdbcTemplate,
|
||||||
MaskingRuleMapper mapper,
|
MaskingRuleMapper mapper,
|
||||||
StructuredDataCatalogProvider catalogProvider
|
DataCatalog dataCatalog,
|
||||||
|
MaskingPolicyCatalog policyCatalog
|
||||||
) {
|
) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.catalog = catalogProvider.catalog();
|
this.dataCatalog = dataCatalog;
|
||||||
Map<String, String> policies = new LinkedHashMap<>();
|
this.policyCatalog = policyCatalog;
|
||||||
catalog.tables().stream()
|
|
||||||
.filter(table -> table.maskingPolicyName() != null)
|
|
||||||
.forEach(table -> policies.put(table.tableName(), table.maskingPolicyName()));
|
|
||||||
this.managedPolicies = Collections.unmodifiableMap(policies);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> managedObjectNames() {
|
public Set<String> managedObjectNames() {
|
||||||
return managedPolicies.keySet();
|
return policyCatalog.objectNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String owner() {
|
public String owner() {
|
||||||
return catalog.owner();
|
return dataCatalog.owner();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isManagedObject(String objectName) {
|
public boolean isManagedObject(String objectName) {
|
||||||
return objectName != null && managedPolicies.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
return policyCatalog.containsObject(objectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
String managedPolicyName(String 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() {
|
public MaskingPolicySyncResult synchronize() {
|
||||||
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
||||||
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
||||||
if (catalog.owner().equalsIgnoreCase(rule.owner())
|
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
||||||
&& rule.ruleEnabled()
|
&& rule.ruleEnabled()
|
||||||
&& managedPolicies.containsKey(rule.objectName())) {
|
&& policyCatalog.containsObject(rule.objectName())) {
|
||||||
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,9 +88,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
int addedColumns = 0;
|
int addedColumns = 0;
|
||||||
int modifiedColumns = 0;
|
int modifiedColumns = 0;
|
||||||
int droppedColumns = 0;
|
int droppedColumns = 0;
|
||||||
for (Map.Entry<String, String> policy : managedPolicies.entrySet()) {
|
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
|
||||||
String objectName = policy.getKey();
|
String objectName = policy.objectName();
|
||||||
String policyName = policy.getValue();
|
String policyName = policy.policyName();
|
||||||
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
||||||
String enableStatus = policyEnableStatus(objectName, policyName);
|
String enableStatus = policyEnableStatus(objectName, policyName);
|
||||||
if (desired.isEmpty()) {
|
if (desired.isEmpty()) {
|
||||||
@@ -147,7 +150,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
SELECT enable
|
SELECT enable
|
||||||
FROM redaction_policies
|
FROM redaction_policies
|
||||||
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
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();
|
return statuses.isEmpty() ? null : statuses.getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +159,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM redaction_columns
|
FROM redaction_columns
|
||||||
WHERE object_owner = ? AND object_name = ?
|
WHERE object_owner = ? AND object_name = ?
|
||||||
""", String.class, catalog.owner(), objectName).stream()
|
""", String.class, dataCatalog.owner(), objectName).stream()
|
||||||
.map(this::requiredColumnName)
|
.map(this::requiredColumnName)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -166,7 +169,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
BEGIN
|
BEGIN
|
||||||
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||||
END;
|
END;
|
||||||
""", catalog.owner(), objectName, policyName);
|
""", dataCatalog.owner(), objectName, policyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void enablePolicy(String objectName, String policyName) {
|
private void enablePolicy(String objectName, String policyName) {
|
||||||
@@ -174,7 +177,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
BEGIN
|
BEGIN
|
||||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||||
END;
|
END;
|
||||||
""", catalog.owner(), objectName, policyName);
|
""", dataCatalog.owner(), objectName, policyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void dropColumn(String objectName, String policyName, String columnName) {
|
private void dropColumn(String objectName, String policyName, String columnName) {
|
||||||
@@ -185,7 +188,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""", catalog.owner(), objectName, policyName, columnName);
|
""", dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addPolicy(
|
private void addPolicy(
|
||||||
@@ -257,9 +260,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
END;
|
END;
|
||||||
""".formatted(functionConstant);
|
""".formatted(functionConstant);
|
||||||
if (regexPattern == null) {
|
if (regexPattern == null) {
|
||||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -282,9 +285,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
END;
|
END;
|
||||||
""".formatted(actionConstant, functionConstant);
|
""".formatted(actionConstant, functionConstant);
|
||||||
if (regexPattern == null) {
|
if (regexPattern == null) {
|
||||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
} else {
|
} 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 => ?
|
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""", catalog.owner(), objectName, columnName, expressionName);
|
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
/** A validated database object-to-redaction-policy mapping. */
|
||||||
|
public record MaskingPolicyTarget(String objectName, String policyName) {
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
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.masking.UserMaskingRule;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
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. */
|
/** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */
|
||||||
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||||
List<ManagedMaskingPolicy> policies = maskingPolicySynchronizer.managedObjectNames().stream()
|
List<MaskingPolicyTarget> policies = maskingPolicySynchronizer.managedPolicies();
|
||||||
.map(objectName -> new ManagedMaskingPolicy(
|
if (policies.isEmpty()) {
|
||||||
objectName, maskingPolicySynchronizer.managedPolicyName(objectName)))
|
return List.of();
|
||||||
.toList();
|
}
|
||||||
return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies);
|
return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
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.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -8,45 +10,35 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
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
|
@Service
|
||||||
public class McpSseService {
|
public class McpSseService {
|
||||||
|
|
||||||
private static final List<ToolSpec> HMM_TOOLS = List.of(
|
private static final String MCP_CALL_PATH = "/mcp (tools/call)";
|
||||||
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 final HmmAiAgentToolRunner agentToolRunner;
|
private final HmmAiAgentToolRunner agentToolRunner;
|
||||||
|
private final SelectAiService selectAiService;
|
||||||
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||||
|
private final McpToolCatalog toolCatalog;
|
||||||
|
private final McpProperties mcpProperties;
|
||||||
|
private final BackofficeProperties backofficeProperties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public McpSseService(
|
public McpSseService(
|
||||||
HmmAiAgentToolRunner agentToolRunner,
|
HmmAiAgentToolRunner agentToolRunner,
|
||||||
|
SelectAiService selectAiService,
|
||||||
HmmMcpBearerAuthenticator bearerAuthenticator,
|
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||||
|
McpToolCatalog toolCatalog,
|
||||||
|
McpProperties mcpProperties,
|
||||||
|
BackofficeProperties backofficeProperties,
|
||||||
ObjectMapper objectMapper
|
ObjectMapper objectMapper
|
||||||
) {
|
) {
|
||||||
this.agentToolRunner = agentToolRunner;
|
this.agentToolRunner = agentToolRunner;
|
||||||
|
this.selectAiService = selectAiService;
|
||||||
this.bearerAuthenticator = bearerAuthenticator;
|
this.bearerAuthenticator = bearerAuthenticator;
|
||||||
|
this.toolCatalog = toolCatalog;
|
||||||
|
this.mcpProperties = mcpProperties;
|
||||||
|
this.backofficeProperties = backofficeProperties;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +46,7 @@ public class McpSseService {
|
|||||||
return handle(contextPath, request, "");
|
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) {
|
public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
|
||||||
bearerAuthenticator.authenticate(bearerToken);
|
bearerAuthenticator.authenticate(bearerToken);
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
@@ -63,8 +55,10 @@ public class McpSseService {
|
|||||||
response.set("id", request.get("id"));
|
response.set("id", request.get("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
String method =
|
||||||
JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
|
request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
||||||
|
JsonNode parameters =
|
||||||
|
request == null ? objectMapper.createObjectNode() : request.path("params");
|
||||||
try {
|
try {
|
||||||
response.set("result", switch (method) {
|
response.set("result", switch (method) {
|
||||||
case "initialize" -> initializeResult(contextPath);
|
case "initialize" -> initializeResult(contextPath);
|
||||||
@@ -73,20 +67,27 @@ public class McpSseService {
|
|||||||
case "tools/call" -> toolsCallResult(parameters, bearerToken);
|
case "tools/call" -> toolsCallResult(parameters, bearerToken);
|
||||||
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
||||||
});
|
});
|
||||||
|
} catch (McpUnauthorizedException exception) {
|
||||||
|
throw exception;
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
response.remove("result");
|
response.remove("result");
|
||||||
ObjectNode error = objectMapper.createObjectNode();
|
ObjectNode error = objectMapper.createObjectNode();
|
||||||
error.put("code", -32000);
|
error.put("code", -32000);
|
||||||
error.put("message", exception.getMessage());
|
error.put("message", safeMessage(exception));
|
||||||
response.set("error", error);
|
response.set("error", error);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<McpToolView> registeredTools() {
|
public List<McpToolView> registeredTools() {
|
||||||
return HMM_TOOLS.stream()
|
return toolCatalog.tools().stream()
|
||||||
.map(tool -> new McpToolView(
|
.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();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@ public class McpSseService {
|
|||||||
ObjectNode result = objectMapper.createObjectNode();
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
result.put("protocolVersion", "2024-11-05");
|
result.put("protocolVersion", "2024-11-05");
|
||||||
ObjectNode serverInfo = objectMapper.createObjectNode();
|
ObjectNode serverInfo = objectMapper.createObjectNode();
|
||||||
serverInfo.put("name", "hmm-hr-backoffice-" + contextPath);
|
serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath);
|
||||||
serverInfo.put("version", "1.0.0");
|
serverInfo.put("version", "1.1.0");
|
||||||
result.set("serverInfo", serverInfo);
|
result.set("serverInfo", serverInfo);
|
||||||
ObjectNode capabilities = objectMapper.createObjectNode();
|
ObjectNode capabilities = objectMapper.createObjectNode();
|
||||||
capabilities.set("tools", objectMapper.createObjectNode());
|
capabilities.set("tools", objectMapper.createObjectNode());
|
||||||
@@ -106,12 +107,12 @@ public class McpSseService {
|
|||||||
private ObjectNode toolsListResult() {
|
private ObjectNode toolsListResult() {
|
||||||
ObjectNode result = objectMapper.createObjectNode();
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
ArrayNode tools = objectMapper.createArrayNode();
|
ArrayNode tools = objectMapper.createArrayNode();
|
||||||
HMM_TOOLS.forEach(tool -> tools.add(toolDefinition(tool)));
|
toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool)));
|
||||||
result.set("tools", tools);
|
result.set("tools", tools);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode toolDefinition(ToolSpec tool) {
|
private ObjectNode toolDefinition(McpToolDefinition tool) {
|
||||||
ObjectNode item = objectMapper.createObjectNode();
|
ObjectNode item = objectMapper.createObjectNode();
|
||||||
item.put("name", tool.name());
|
item.put("name", tool.name());
|
||||||
item.put("description", tool.description());
|
item.put("description", tool.description());
|
||||||
@@ -133,23 +134,34 @@ public class McpSseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
|
private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
|
||||||
String requestedName = params.path("name").asText("");
|
McpToolDefinition tool = toolCatalog.require(params.path("name").asText(""));
|
||||||
ToolSpec tool = HMM_TOOLS.stream()
|
|
||||||
.filter(candidate -> candidate.name().equals(requestedName))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new AppException("등록되지 않은 HMM MCP tool입니다: " + requestedName));
|
|
||||||
String argument = params.path("arguments").path(tool.argumentName()).asText("").trim();
|
String argument = params.path("arguments").path(tool.argumentName()).asText("").trim();
|
||||||
if (argument.isBlank()) {
|
if (argument.isBlank()) {
|
||||||
throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다.");
|
throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonNode toolResponse;
|
||||||
|
if (tool.agentTool()) {
|
||||||
ObjectNode input = objectMapper.createObjectNode();
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
input.put(tool.agentParameterName(), argument);
|
input.put(tool.targetParameterName(), argument);
|
||||||
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken);
|
toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken);
|
||||||
|
} else {
|
||||||
|
toolResponse = selectAiService.generateAndExecute(bearerToken, argument);
|
||||||
|
}
|
||||||
|
|
||||||
ObjectNode payload = objectMapper.createObjectNode();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("toolName", tool.name());
|
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);
|
payload.set("response", toolResponse);
|
||||||
|
|
||||||
ObjectNode result = objectMapper.createObjectNode();
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
ArrayNode content = objectMapper.createArrayNode();
|
ArrayNode content = objectMapper.createArrayNode();
|
||||||
ObjectNode text = objectMapper.createObjectNode();
|
ObjectNode text = objectMapper.createObjectNode();
|
||||||
@@ -161,6 +173,11 @@ public class McpSseService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String safeMessage(Exception exception) {
|
||||||
|
String message = exception.getMessage();
|
||||||
|
return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message;
|
||||||
|
}
|
||||||
|
|
||||||
private String pretty(Object value) {
|
private String pretty(Object value) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||||
@@ -168,19 +185,4 @@ public class McpSseService {
|
|||||||
return String.valueOf(value);
|
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)
|
|
||||||
? "확인할 휴가·근태 용어, 동의어 또는 코드입니다."
|
|
||||||
: "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface McpToolCatalog {
|
||||||
|
|
||||||
|
List<McpToolDefinition> tools();
|
||||||
|
|
||||||
|
McpToolDefinition require(String name);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog;
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -35,7 +34,7 @@ public class SchemaMetadataService {
|
|||||||
return structuredDataService.tables();
|
return structuredDataService.tables();
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataCatalog catalog() {
|
public DataCatalog catalog() {
|
||||||
return structuredDataService.catalog();
|
return structuredDataService.catalog();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +46,8 @@ public class SchemaMetadataService {
|
|||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = table.tableName();
|
String tableName = table.tableName();
|
||||||
String tableComment = tableComment(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);
|
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||||
return new SchemaMetadataView(
|
return new SchemaMetadataView(
|
||||||
table,
|
table,
|
||||||
@@ -77,6 +77,7 @@ public class SchemaMetadataService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
|
requireAnnotationTable(table);
|
||||||
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ public class SchemaMetadataService {
|
|||||||
String annotationValue
|
String annotationValue
|
||||||
) {
|
) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
|
requireAnnotationTable(table);
|
||||||
String column = requireColumn(table.tableName(), columnName);
|
String column = requireColumn(table.tableName(), columnName);
|
||||||
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
@@ -227,6 +229,12 @@ public class SchemaMetadataService {
|
|||||||
return column;
|
return column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requireAnnotationTable(StructuredDataTable table) {
|
||||||
|
if (!table.isTable()) {
|
||||||
|
throw new AppException("Oracle annotation은 TABLE 객체에서만 수정할 수 있습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String requireSimpleName(String value, String label) {
|
private String requireSimpleName(String value, String label) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
throw new AppException(label + "은(는) 필수입니다.");
|
throw new AppException(label + "은(는) 필수입니다.");
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
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.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
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.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -17,46 +23,21 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class SecuritySqlScriptService {
|
public class SecuritySqlScriptService {
|
||||||
|
|
||||||
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||||
new ScriptDefinition(
|
private static final Pattern RESOURCE_PATH = Pattern.compile(
|
||||||
"aso-masking-metadata",
|
"(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
|
||||||
"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 final List<ScriptDefinition> scripts;
|
||||||
|
|
||||||
|
public SecuritySqlScriptService(
|
||||||
|
SecuritySqlScriptProperties properties,
|
||||||
|
ObjectMapper objectMapper
|
||||||
|
) {
|
||||||
|
scripts = parse(properties.scripts(), objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
public List<SecuritySqlScriptSummary> list() {
|
public List<SecuritySqlScriptSummary> list() {
|
||||||
return CURATED_SCRIPTS.stream()
|
return scripts.stream()
|
||||||
.map(definition -> new SecuritySqlScriptSummary(
|
.map(definition -> new SecuritySqlScriptSummary(
|
||||||
definition.scriptId(),
|
definition.scriptId(),
|
||||||
definition.category(),
|
definition.category(),
|
||||||
@@ -68,7 +49,7 @@ public class SecuritySqlScriptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SecuritySqlScript find(String scriptId) {
|
public SecuritySqlScript find(String scriptId) {
|
||||||
ScriptDefinition definition = CURATED_SCRIPTS.stream()
|
ScriptDefinition definition = scripts.stream()
|
||||||
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
.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 scriptId,
|
||||||
String category,
|
String category,
|
||||||
String fileName,
|
String fileName,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
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.StructuredDataPreview;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -12,18 +11,20 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class StructuredDataService {
|
public class StructuredDataService {
|
||||||
|
|
||||||
|
private static final int ROW_LIMIT = 50;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final StructuredDataCatalog catalog;
|
private final DataCatalog catalog;
|
||||||
|
|
||||||
public StructuredDataService(
|
public StructuredDataService(
|
||||||
JdbcTemplate jdbcTemplate,
|
JdbcTemplate jdbcTemplate,
|
||||||
StructuredDataCatalogProvider catalogProvider
|
DataCatalog catalog
|
||||||
) {
|
) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.catalog = catalogProvider.catalog();
|
this.catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataCatalog catalog() {
|
public DataCatalog catalog() {
|
||||||
return catalog;
|
return catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +33,15 @@ public class StructuredDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
return catalog.tables();
|
return catalog.objects();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String defaultKey() {
|
public String defaultKey() {
|
||||||
return catalog.tables().getFirst().key();
|
return catalog.objects().getFirst().key();
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataTable requireTable(String key) {
|
public StructuredDataTable requireTable(String key) {
|
||||||
return catalog.tables().stream()
|
return catalog.require(key);
|
||||||
.filter(table -> table.key().equals(key))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataPreview preview(String key) {
|
public StructuredDataPreview preview(String key) {
|
||||||
@@ -61,17 +59,17 @@ public class StructuredDataService {
|
|||||||
if (columns.isEmpty()) {
|
if (columns.isEmpty()) {
|
||||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
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)) {
|
if (!columns.containsAll(previewColumns)) {
|
||||||
throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다.");
|
throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
previewSql(table, previewColumns), catalog.rowLimit());
|
previewSql(table, previewColumns), ROW_LIMIT);
|
||||||
return new StructuredDataPreview(table, previewColumns, rows, catalog.rowLimit());
|
return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT);
|
||||||
} catch (DataAccessException exception) {
|
} catch (DataAccessException exception) {
|
||||||
throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.sourceName() + "의 "
|
throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner()
|
||||||
+ catalog.owner() + " 조회 권한과 대상 테이블 상태를 확인하세요.");
|
+ " 조회 권한과 대상 객체 상태를 확인하세요.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +81,20 @@ public class StructuredDataService {
|
|||||||
return previewSql(table, table.previewColumns());
|
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());
|
StructuredDataTable approved = requireTable(table.key());
|
||||||
if (!approved.tableName().equals(table.tableName())) {
|
if (!approved.tableName().equals(table.tableName())) {
|
||||||
throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
throw new AppException("선택할 수 없는 카탈로그 객체입니다.");
|
||||||
}
|
}
|
||||||
String projection = previewColumns == null || previewColumns.isEmpty()
|
String projection = previewColumns == null || previewColumns.isEmpty()
|
||||||
? "*"
|
? "*"
|
||||||
: previewColumns.stream().map(column -> "\"" + column + "\"")
|
: previewColumns.stream()
|
||||||
.reduce((left, right) -> left + ", " + right).orElseThrow();
|
.map(column -> "\"" + column + "\"")
|
||||||
|
.reduce((left, right) -> left + ", " + right)
|
||||||
|
.orElseThrow();
|
||||||
return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName()
|
return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName()
|
||||||
+ "\" WHERE ROWNUM <= ?";
|
+ "\" WHERE ROWNUM <= ?";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class DashboardController {
|
|||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
public String dashboard(Model model) {
|
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.
|
// It intentionally does not query legacy CB_* VPD catalog objects.
|
||||||
model.addAttribute("users", userService.findAll());
|
model.addAttribute("users", userService.findAll());
|
||||||
model.addAttribute("groups", groupService.findAll());
|
model.addAttribute("groups", groupService.findAll());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.web;
|
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.domain.mcp.McpReasoningCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||||
@@ -24,7 +24,7 @@ public class McpReasoningController {
|
|||||||
private final McpReasoningService reasoningService;
|
private final McpReasoningService reasoningService;
|
||||||
private final BearerTokenService tokenService;
|
private final BearerTokenService tokenService;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final BackofficeProperties properties;
|
private final McpProperties mcpProperties;
|
||||||
|
|
||||||
public McpReasoningController(
|
public McpReasoningController(
|
||||||
McpToolRegistry toolRegistry,
|
McpToolRegistry toolRegistry,
|
||||||
@@ -32,14 +32,14 @@ public class McpReasoningController {
|
|||||||
McpReasoningService reasoningService,
|
McpReasoningService reasoningService,
|
||||||
BearerTokenService tokenService,
|
BearerTokenService tokenService,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
BackofficeProperties properties
|
McpProperties mcpProperties
|
||||||
) {
|
) {
|
||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
this.mcpSseService = mcpSseService;
|
this.mcpSseService = mcpSseService;
|
||||||
this.reasoningService = reasoningService;
|
this.reasoningService = reasoningService;
|
||||||
this.tokenService = tokenService;
|
this.tokenService = tokenService;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.properties = properties;
|
this.mcpProperties = mcpProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mcp-reasoning")
|
@GetMapping("/mcp-reasoning")
|
||||||
@@ -65,7 +65,7 @@ public class McpReasoningController {
|
|||||||
@GetMapping("/mcp-sse")
|
@GetMapping("/mcp-sse")
|
||||||
public String ssePage(Model model) {
|
public String ssePage(Model model) {
|
||||||
model.addAttribute("tools", mcpSseService.registeredTools());
|
model.addAttribute("tools", mcpSseService.registeredTools());
|
||||||
model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl());
|
model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl());
|
||||||
return "mcp-sse";
|
return "mcp-sse";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package com.cloudhandson.vpdbackoffice.web;
|
|||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService;
|
import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.SettingService;
|
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.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -16,21 +16,21 @@ public class SettingController {
|
|||||||
|
|
||||||
private final SettingService settingService;
|
private final SettingService settingService;
|
||||||
private final BackofficeSchemaService backofficeSchemaService;
|
private final BackofficeSchemaService backofficeSchemaService;
|
||||||
private final BackofficeProperties properties;
|
private final McpProperties mcpProperties;
|
||||||
|
|
||||||
public SettingController(
|
public SettingController(
|
||||||
SettingService settingService,
|
SettingService settingService,
|
||||||
BackofficeSchemaService backofficeSchemaService,
|
BackofficeSchemaService backofficeSchemaService,
|
||||||
BackofficeProperties properties
|
McpProperties mcpProperties
|
||||||
) {
|
) {
|
||||||
this.settingService = settingService;
|
this.settingService = settingService;
|
||||||
this.backofficeSchemaService = backofficeSchemaService;
|
this.backofficeSchemaService = backofficeSchemaService;
|
||||||
this.properties = properties;
|
this.mcpProperties = mcpProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/settings")
|
@GetMapping("/settings")
|
||||||
public String settings(Model model) {
|
public String settings(Model model) {
|
||||||
model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl());
|
model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl());
|
||||||
try {
|
try {
|
||||||
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
|
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
|
||||||
} catch (DataAccessException exception) {
|
} catch (DataAccessException exception) {
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ server:
|
|||||||
same-site: lax
|
same-site: lax
|
||||||
|
|
||||||
backoffice:
|
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:
|
security:
|
||||||
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
|
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
|
||||||
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
|
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
|
||||||
@@ -52,8 +49,6 @@ backoffice:
|
|||||||
token:
|
token:
|
||||||
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
||||||
ords:
|
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:}
|
base-url: ${BACKOFFICE_ORDS_BASE_URL:}
|
||||||
timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s
|
timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s
|
||||||
agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s
|
agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s
|
||||||
@@ -61,8 +56,6 @@ backoffice:
|
|||||||
url: ${BACKOFFICE_ORDS_DB_URL:}
|
url: ${BACKOFFICE_ORDS_DB_URL:}
|
||||||
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
||||||
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
||||||
mcp:
|
|
||||||
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp}
|
|
||||||
ai:
|
ai:
|
||||||
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||||
provider: ${BACKOFFICE_AI_PROVIDER:openai}
|
provider: ${BACKOFFICE_AI_PROVIDER:openai}
|
||||||
@@ -75,3 +68,27 @@ backoffice:
|
|||||||
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
||||||
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
||||||
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
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:}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="backoffice-can-mutate" th:content="${canMutate}">
|
<meta name="backoffice-can-mutate" th:content="${canMutate}">
|
||||||
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
|
<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 & AI Backoffice</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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">
|
<link href="/css/app.css" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
|
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
|
||||||
<div class="container rw-nav-top">
|
<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 & AI Backoffice</a>
|
||||||
<div class="rw-menu" aria-label="주요 메뉴">
|
<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="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>
|
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="login-brand">
|
<div class="login-brand">
|
||||||
<span class="login-mark">접근</span>
|
<span class="login-mark">접근</span>
|
||||||
<div>
|
<div>
|
||||||
<h1>데이터 접근 제어 콘솔</h1>
|
<h1 th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data & AI Backoffice</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${policyStatuses}">
|
<tr th:each="status : ${policyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES</code></td>
|
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${status.policyName()}">HMM_EMPLOYEE_PII_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">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>
|
<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">
|
<section class="content-band">
|
||||||
<h2>컬럼 마스킹 규칙 등록</h2>
|
<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">
|
<form method="post" action="/masking-rules" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
<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>
|
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="rule : ${rules}">
|
<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.ruleName()}">주민번호 기본 마스킹</td>
|
||||||
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
||||||
<td th:text="${rule.description() ?: '-'}">설명</td>
|
<td th:text="${rule.description() ?: '-'}">설명</td>
|
||||||
@@ -209,11 +209,11 @@
|
|||||||
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
||||||
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
||||||
th:value="|${object.objectId()}:${columnName}|"
|
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>
|
</optgroup>
|
||||||
</th:block>
|
</th:block>
|
||||||
</select>
|
</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>
|
</label>
|
||||||
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>MCP 연동</h1>
|
<h1>MCP 연동</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>HMM HR MCP는 표준 용어 변환, HR 데이터 조회, HR 규정 PDF 검색의 세 도구를 제공합니다. 모든 도구는 읽기 전용이며, 휴가·근태 표현이 모호하면 용어 변환을 먼저 사용합니다.</p>
|
<p>환경 설정으로 승인한 MCP 도구만 제공합니다. <code>tools/list</code>의 설명과 입력 스키마를 확인한 뒤 사용자 Bearer Token으로 호출하세요.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -22,18 +22,18 @@
|
|||||||
<div class="mcp-service-grid">
|
<div class="mcp-service-grid">
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>공개 MCP Endpoint</span>
|
<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>
|
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>인증</span>
|
<span>인증</span>
|
||||||
<strong><code>Authorization: Bearer <HMM MCP Token></code></strong>
|
<strong><code>Authorization: Bearer <사용자 토큰></code></strong>
|
||||||
<small>고정 MCP 서버 토큰으로 인증합니다. DB 비밀번호와 Wallet은 전달하지 않습니다.</small>
|
<small>선택 사용자의 활성 토큰으로 인증합니다. DB 비밀번호와 Wallet은 전달하지 않습니다.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>도구 수</span>
|
<span>도구 수</span>
|
||||||
<strong>3개</strong>
|
<strong th:text="${#lists.size(tools) + '개'}">0개</strong>
|
||||||
<small>용어 표준화 → HR 데이터/규정 검색 순서로 사용합니다.</small>
|
<small>배포 환경의 MCP 도구 allowlist가 적용됩니다.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
<table class="table align-middle">
|
<table class="table align-middle">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>HMM MCP</th>
|
<th>MCP</th>
|
||||||
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></td>
|
<td><code th:text="${hmmMcpPublicUrl}">https://example.com/mcp</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Transport</th>
|
<th>Transport</th>
|
||||||
@@ -55,11 +55,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>백오피스 호환 Endpoint</th>
|
<th>백오피스 호환 Endpoint</th>
|
||||||
<td><code>/mcp</code> — 로그인 세션에서 같은 HMM 도구 계약을 확인합니다.</td>
|
<td><code>/mcp</code> — 같은 설정 기반 도구 계약을 제공합니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Auth</th>
|
<th>Auth</th>
|
||||||
<td><code>Authorization: Bearer <HMM MCP Token></code> — 공개 MCP 서버의 고정 인증 토큰입니다.</td>
|
<td><code>Authorization: Bearer <사용자 토큰></code> — 활성 업무 사용자 토큰입니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Methods</th>
|
<th>Methods</th>
|
||||||
@@ -79,20 +79,18 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Object</th>
|
<th>Object</th>
|
||||||
<th>Agent Tool</th>
|
<th>실행 대상</th>
|
||||||
<th>Instruction / parameter mapping</th>
|
<th>Instruction</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="tool : ${tools}">
|
<tr th:each="tool : ${tools}">
|
||||||
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
||||||
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</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>
|
<td>
|
||||||
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">정확한 argument 이름과 필수 여부는 <code>tools/list.inputSchema</code>를 사용합니다.</small>
|
||||||
<code>resolve_hr_term.term</code> → 표준 용어·코드 · <code>search_hr_data.query</code> → HR 데이터 · <code>search_hr_policy.query</code> → 규정 PDF 검색
|
|
||||||
</small>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${#lists.isEmpty(tools)}">
|
<tr th:if="${#lists.isEmpty(tools)}">
|
||||||
@@ -108,9 +106,9 @@
|
|||||||
<summary>tools/call parameter 예시 보기</summary>
|
<summary>tools/call parameter 예시 보기</summary>
|
||||||
<h2>tools/call Arguments</h2>
|
<h2>tools/call Arguments</h2>
|
||||||
<pre class="code-block">{
|
<pre class="code-block">{
|
||||||
"term": "연차 이월"
|
"<tools/list의 argument 이름>": "<조회할 자연어 질문>"
|
||||||
}</pre>
|
}</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>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -144,9 +142,9 @@
|
|||||||
"id": 3,
|
"id": 3,
|
||||||
"method": "tools/call",
|
"method": "tools/call",
|
||||||
"params": {
|
"params": {
|
||||||
"name": "search_hr_policy",
|
"name": "<tools/list의 name>",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"query": "연차 휴가 이월 기준과 제한을 알려줘."
|
"<required argument>": "조회할 자연어 질문"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}</pre>
|
}</pre>
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${maskingPolicyStatuses}">
|
<tr th:each="status : ${maskingPolicyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES</code></td>
|
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${status.policyName()}">HMM_EMPLOYEE_PII_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">0</td>
|
<td th:text="${status.appliedColumnCount()}">0</td>
|
||||||
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>역할</h1>
|
<h1>역할</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>스마일게이트 Data & AI PoC 운영 역할을 관리합니다. 역할은 사용자에게 직접 부여하거나 그룹을 통해 부여할 수 있습니다.</p>
|
<p><span th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data & AI Backoffice</span>의 운영 역할을 관리합니다. 역할은 사용자에게 직접 부여하거나 그룹을 통해 부여할 수 있습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>
|
<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 생성 근거로 들어갑니다.
|
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, JSON 카탈로그가 승인한 업무 테이블만 대상으로 합니다.</p>
|
<p class="mb-0">Oracle annotation은 TABLE에서만 관리합니다. 임의 스키마나 임의 객체는 수정하지 않습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>테이블 선택</h2>
|
<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>
|
</div>
|
||||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">6</span>
|
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">6</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/schema-metadata(table=${entry.key()})}">
|
th:href="@{/schema-metadata(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">직원 원장</strong>
|
<strong th:text="${entry.businessName()}">직원 원장</strong>
|
||||||
<code th:text="${entry.tableName()}">HMM_HR_EMPLOYEES</code>
|
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
|
||||||
<small th:text="${entry.description()}">직원·조직 정보</small>
|
<small><span th:text="${entry.objectType()}">TABLE</span> · <span th:text="${entry.description()}">설명</span></small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -43,10 +43,10 @@
|
|||||||
<section class="content-band" th:if="${metadata}">
|
<section class="content-band" th:if="${metadata}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<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>
|
<h2 class="mt-2" th:text="${metadata.table().businessName()}">직원 원장</h2>
|
||||||
<p class="section-subtitle">
|
<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>
|
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +63,13 @@
|
|||||||
<button class="btn rw-btn-primary mt-2" type="submit">테이블 comment 저장</button>
|
<button class="btn rw-btn-primary mt-2" type="submit">테이블 comment 저장</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="section-heading">
|
<div class="section-heading" th:if="${metadata.table().isTable()}">
|
||||||
<div>
|
<div>
|
||||||
<h3>Table annotations</h3>
|
<h3>Table annotations</h3>
|
||||||
<p class="section-subtitle">기존 annotation은 값 수정 또는 빈 값 저장으로 삭제할 수 있습니다.</p>
|
<p class="section-subtitle">기존 annotation은 값 수정 또는 빈 값 저장으로 삭제할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive" th:if="${metadata.table().isTable()}">
|
||||||
<table class="table table-sm align-middle">
|
<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>
|
<thead><tr><th style="width: 220px;">Annotation</th><th>Value</th><th style="width: 110px;"></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<button class="btn btn-sm rw-btn-primary mt-2" type="submit">컬럼 comment 저장</button>
|
<button class="btn btn-sm rw-btn-primary mt-2" type="submit">컬럼 comment 저장</button>
|
||||||
</form>
|
</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">
|
<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>
|
<thead><tr><th style="width: 220px;">Annotation</th><th>Value</th><th style="width: 110px;"></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>시스템 설정</h1>
|
<h1>시스템 설정</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>HMM HR 질의와 Agent Factory는 HMM MCP를 사용합니다. ORDS는 기존 VPD/ORDS 운영 기능이 필요한 경우에만 별도로 설정합니다.</p>
|
<p><span th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data & AI Backoffice</span>의 MCP 공개 주소와 선택형 레거시 ORDS 연결을 확인합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="architecture-kicker">HMM MCP · 현재 사용</span>
|
<span class="architecture-kicker">MCP · 현재 사용</span>
|
||||||
<h2>HMM HR Agent 도구</h2>
|
<h2>설정 기반 MCP 도구</h2>
|
||||||
<p class="section-subtitle">용어 정규화, HR 데이터 조회, HR 정책 문서 검색은 이 MCP의 DBMS_CLOUD_AI_AGENT 도구를 사용합니다.</p>
|
<p class="section-subtitle">환경에서 승인한 도구 계약과 사용자 Bearer Token 인증을 사용합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="architecture-kicker">선택형 레거시 연동</span>
|
<span class="architecture-kicker">선택형 레거시 연동</span>
|
||||||
<h2>ORDS Base URL</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/settings/ords" class="form-grid">
|
<form method="post" action="/settings/ords" class="form-grid">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<h1>정형 데이터 조회</h1>
|
<h1>정형 데이터 조회</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p th:text="${catalog.pageHelp()}">승인된 업무 원장만 읽기 전용으로 조회합니다.</p>
|
<p>환경 설정에 등록된 <span th:text="${product.dataName()}">업무 데이터</span> 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.</p>
|
||||||
<p class="mb-0" th:text="${'한 번에 최대 ' + catalog.rowLimit() + '건까지만 표시합니다.'}">한 번에 최대 50건까지만 표시합니다.</p>
|
<p class="mb-0">한 번에 최대 50건까지만 표시합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>조회할 원장 선택</h2>
|
<h2>조회할 업무 데이터 선택</h2>
|
||||||
<p class="section-subtitle" th:text="${catalog.catalogDescription()}">승인된 정형 테이블만 표시합니다.</p>
|
<p class="section-subtitle"><code th:text="${catalogOwner}">OWNER</code> 스키마에서 환경 설정으로 승인한 TABLE/VIEW만 표시합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="structured-table-grid">
|
<div class="structured-table-grid">
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/structured-data(table=${entry.key()})}">
|
th:href="@{/structured-data(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">직원 원장</strong>
|
<strong th:text="${entry.businessName()}">직원 원장</strong>
|
||||||
<code th:text="${entry.tableName()}">HMM_HR_EMPLOYEES</code>
|
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
|
||||||
<small th:text="${entry.description()}">직원·조직 정보</small>
|
<small><span th:text="${entry.objectType()}">TABLE</span> · <span th:text="${entry.description()}">업무 데이터</span></small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 th:text="${preview.table().businessName()}">직원 원장</h2>
|
<h2 th:text="${preview.table().businessName()}">직원 원장</h2>
|
||||||
<p class="section-subtitle">
|
<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>
|
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<select class="form-select" name="userId" required>
|
<select class="form-select" name="userId" required>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
data-result=${columnRule.template().previewResult()},
|
data-result=${columnRule.template().previewResult()},
|
||||||
data-aso-function=${columnRule.template().asoFunction()},
|
data-aso-function=${columnRule.template().asoFunction()},
|
||||||
data-context=${'MR_' + columnRule.columnId()}"
|
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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
|
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="userRule : ${userRules}">
|
<tr th:each="userRule : ${userRules}">
|
||||||
<td th:text="${userRule.username()}">KB_VPD_ADMIN</td>
|
<td th:text="${userRule.username()}">demo-user</td>
|
||||||
<td><code th:text="${userRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
|
<td><code th:text="${userRule.targetLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</code></td>
|
||||||
<td th:text="${userRule.ruleLabel()}">주민번호 기본 마스킹 · 주민등록번호 부분 마스킹</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><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>
|
<td th:text="${userRule.activeYn()}">Y</td>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>사용자</h1>
|
<h1>사용자</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>스마일게이트 Data & AI PoC 도구를 사용할 운영 사용자를 관리합니다. 게임 서비스 이용자나 Oracle DB 계정과는 별개의 백오피스 관리 대상입니다.</p>
|
<p><span th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data & AI Backoffice</span>에서 사용할 운영 사용자를 관리합니다. 업무 시스템 사용자와 Oracle DB 계정은 별개로 관리됩니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="policy : ${policies}">
|
<tr th:each="policy : ${policies}">
|
||||||
<td><code th:text="${policy.objectDisplayName()}">POC_2.KB_CONTRACTS</code></td>
|
<td><code th:text="${policy.objectDisplayName()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${policy.policyName()}">KB_KB_CONTRACTS_ROW_POLICY</code></td>
|
<td><code th:text="${policy.policyName()}">ROW_ACCESS_POLICY</code></td>
|
||||||
<td th:text="${policy.statementTypes()}">SELECT</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><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>
|
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.mock;
|
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.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
class MaskingPolicySynchronizerTest {
|
class MaskingPolicySynchronizerTest {
|
||||||
@@ -28,11 +29,24 @@ class MaskingPolicySynchronizerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hmmEmployeePolicyComesFromTheSharedJsonCatalog() {
|
void hmmEmployeePolicyComesFromEnvironmentPolicyCatalog() {
|
||||||
var catalogProvider = new StructuredDataCatalogProvider(
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json"));
|
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(
|
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.owner()).isEqualTo("ADMIN");
|
||||||
assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES"))
|
assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES"))
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
@@ -10,16 +13,70 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
class McpSseServiceTest {
|
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 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 =
|
private final HmmMcpBearerAuthenticator bearerAuthenticator =
|
||||||
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
|
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
|
||||||
private final McpSseService service =
|
private final McpSseService service = new McpSseService(
|
||||||
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
|
agentToolRunner,
|
||||||
|
mock(SelectAiService.class),
|
||||||
|
bearerAuthenticator,
|
||||||
|
toolCatalog,
|
||||||
|
mcpProperties,
|
||||||
|
new BackofficeProperties(null, null, null, null, null),
|
||||||
|
objectMapper);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
|
void listsEnvironmentConfiguredTermDataAndPolicyTools() {
|
||||||
ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
|
ObjectNode response =
|
||||||
|
service.handle("default", request(1, "tools/list"), "valid-token");
|
||||||
|
|
||||||
var tools = response.path("result").path("tools");
|
var tools = response.path("result").path("tools");
|
||||||
assertThat(tools).hasSize(3);
|
assertThat(tools).hasSize(3);
|
||||||
@@ -31,13 +88,10 @@ class McpSseServiceTest {
|
|||||||
assertThat(tools.get(1).path("inputSchema").path("required"))
|
assertThat(tools.get(1).path("inputSchema").path("required"))
|
||||||
.extracting(JsonNode::asText)
|
.extracting(JsonNode::asText)
|
||||||
.containsExactly("query");
|
.containsExactly("query");
|
||||||
assertThat(tools.get(2).path("inputSchema").path("required"))
|
|
||||||
.extracting(JsonNode::asText)
|
|
||||||
.containsExactly("query");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void callsHMMTermResolverWithTheApprovedAgentToolAndInputName() {
|
void callsConfiguredAgentToolWithItsDeclaredInputName() {
|
||||||
ObjectNode request = request(2, "tools/call");
|
ObjectNode request = request(2, "tools/call");
|
||||||
ObjectNode params = request.putObject("params");
|
ObjectNode params = request.putObject("params");
|
||||||
params.put("name", "resolve_hr_term");
|
params.put("name", "resolve_hr_term");
|
||||||
@@ -50,19 +104,19 @@ class McpSseServiceTest {
|
|||||||
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
|
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
|
||||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
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
|
@Test
|
||||||
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||||
McpSseService rejectingService = new McpSseService(
|
McpSseService rejectingService = new McpSseService(
|
||||||
agentToolRunner,
|
agentToolRunner,
|
||||||
|
mock(SelectAiService.class),
|
||||||
token -> {
|
token -> {
|
||||||
throw new McpUnauthorizedException();
|
throw new McpUnauthorizedException();
|
||||||
},
|
},
|
||||||
|
toolCatalog,
|
||||||
|
mcpProperties,
|
||||||
|
new BackofficeProperties(null, null, null, null, null),
|
||||||
objectMapper);
|
objectMapper);
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
assertThatThrownBy(() ->
|
||||||
@@ -71,16 +125,38 @@ class McpSseServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
|
void rejectsUnknownToolsWithoutCallingTheRunner() {
|
||||||
ObjectNode request = request(3, "tools/call");
|
ObjectNode request = request(3, "tools/call");
|
||||||
ObjectNode params = request.putObject("params");
|
ObjectNode params = request.putObject("params");
|
||||||
params.put("name", "ords.query.kb_select_ai_vpd");
|
params.put("name", "unconfigured.tool");
|
||||||
params.putObject("arguments").put("prompt", "legacy query");
|
params.putObject("arguments").put("prompt", "query");
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request, "valid-token");
|
ObjectNode response = service.handle("default", request, "valid-token");
|
||||||
|
|
||||||
assertThat(response.path("result").isMissingNode()).isTrue();
|
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) {
|
private ObjectNode request(int id, String method) {
|
||||||
@@ -91,7 +167,8 @@ class McpSseServiceTest {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class CapturingHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
|
private final class CapturingHmmAiAgentToolRunner
|
||||||
|
implements HmmAiAgentToolRunner {
|
||||||
|
|
||||||
private String toolName;
|
private String toolName;
|
||||||
private ObjectNode input;
|
private ObjectNode input;
|
||||||
@@ -106,9 +183,7 @@ class McpSseServiceTest {
|
|||||||
toolName = requestedToolName;
|
toolName = requestedToolName;
|
||||||
input = requestedInput.deepCopy();
|
input = requestedInput.deepCopy();
|
||||||
this.bearerToken = bearerToken;
|
this.bearerToken = bearerToken;
|
||||||
return objectMapper.createObjectNode()
|
return objectMapper.createObjectNode().put("status", "ok");
|
||||||
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
|
|
||||||
.put("termName", "연차 이월");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,33 +3,51 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
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;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class SecuritySqlScriptServiceTest {
|
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
|
@Test
|
||||||
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
|
void exposesOnlyEnvironmentAllowlistedBundledScripts() {
|
||||||
assertThat(service.list())
|
assertThat(service.list())
|
||||||
.extracting(item -> item.fileName())
|
.extracting(item -> item.fileName())
|
||||||
.containsExactly(
|
.containsExactly("72_hmm_leave_team_vpd.sql");
|
||||||
"62_kb_aso_masking_backoffice_metadata.sql",
|
|
||||||
"63_kb_aso_masking_rule_runtime.sql",
|
|
||||||
"64_kb_aso_masking_default_column_rules.sql",
|
|
||||||
"65_kb_select_ai_vpd_query_api.sql",
|
|
||||||
"66_kb_select_ai_vpd_query_ords.sql"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(service.find("select-ai-vpd-ords").source())
|
assertThat(service.find("hmm-leave-vpd").source())
|
||||||
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
|
.contains("HMM_ACCESS_CTX_PKG")
|
||||||
.contains("Authorization: Bearer <VPD token>");
|
.contains("HMM_LEAVE_VPD_FILTER");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() {
|
void rejectsUnknownScriptIdsInsteadOfResolvingARequestPath() {
|
||||||
assertThatThrownBy(() -> service.find("../../etc/passwd"))
|
assertThatThrownBy(() -> service.find("../../etc/passwd"))
|
||||||
.isInstanceOf(AppException.class)
|
.isInstanceOf(AppException.class)
|
||||||
.hasMessageContaining("조회할 수 없는");
|
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,61 +4,72 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
class StructuredDataServiceTest {
|
class StructuredDataServiceTest {
|
||||||
|
|
||||||
private final StructuredDataCatalogProvider catalogProvider = new StructuredDataCatalogProvider(
|
private static final String HMM_OBJECTS = """
|
||||||
new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json"));
|
[
|
||||||
private final StructuredDataService service = new StructuredDataService(
|
{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE",
|
||||||
mock(JdbcTemplate.class), catalogProvider);
|
"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
|
@Test
|
||||||
void exposesOnlyTheHMMTablesFromTheJsonCatalog() {
|
void exposesOnlyEnvironmentConfiguredTablesAndViews() {
|
||||||
assertThat(service.catalog().sourceName()).isEqualTo("HMMAIPOC");
|
|
||||||
assertThat(service.owner()).isEqualTo("ADMIN");
|
assertThat(service.owner()).isEqualTo("ADMIN");
|
||||||
assertThat(service.tables())
|
assertThat(service.tables())
|
||||||
.extracting(table -> table.tableName())
|
.extracting(table -> table.tableName())
|
||||||
.containsExactly(
|
.containsExactly(
|
||||||
"HMM_ORG_TEAMS",
|
"HMM_ORG_TEAMS",
|
||||||
"HMM_HR_EMPLOYEES",
|
"HMM_HR_EMPLOYEES",
|
||||||
"HMM_LEAVE_BALANCES",
|
"HMM_LEAVE_BALANCES");
|
||||||
"HMM_LEAVE_REQUESTS",
|
assertThat(service.requireTable("leave-balances").objectType()).isEqualTo("VIEW");
|
||||||
"HMM_ATTENDANCE_DAILY",
|
|
||||||
"HMM_HR_TERMS");
|
|
||||||
assertThat(service.previewSql(service.requireTable("employees")))
|
assertThat(service.previewSql(service.requireTable("employees")))
|
||||||
.isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?");
|
.isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsAnyTableOutsideTheServerSideAllowlist() {
|
void rejectsAnyObjectOutsideTheServerSideAllowlist() {
|
||||||
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
|
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
|
||||||
.isInstanceOf(AppException.class)
|
.isInstanceOf(AppException.class)
|
||||||
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
|
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsUnsafeOracleIdentifiersInAnExternalCatalog() {
|
void rejectsUnsafeOracleIdentifiers() {
|
||||||
String unsafeJson = """
|
assertThatThrownBy(() -> new EnvironmentDataCatalog(
|
||||||
{
|
new CatalogProperties("ADMIN; DROP USER X", HMM_OBJECTS), new ObjectMapper()))
|
||||||
"sourceName": "demo",
|
.isInstanceOf(IllegalStateException.class)
|
||||||
"owner": "ADMIN; DROP USER X",
|
.hasMessageContaining("BACKOFFICE_CATALOG_OWNER");
|
||||||
"pageHelp": "help",
|
|
||||||
"catalogDescription": "description",
|
|
||||||
"rowLimit": 10,
|
|
||||||
"tables": [
|
|
||||||
{"key":"employees","tableName":"EMPLOYEES","businessName":"직원","description":"직원"}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(
|
assertThatThrownBy(() -> new EnvironmentDataCatalog(
|
||||||
new ObjectMapper(), new ByteArrayResource(unsafeJson.getBytes())))
|
new CatalogProperties("ADMIN", duplicate), new ObjectMapper()))
|
||||||
.isInstanceOf(IllegalStateException.class)
|
.isInstanceOf(IllegalStateException.class)
|
||||||
.hasMessageContaining("owner 형식이 올바르지 않습니다");
|
.hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class GuidedFlowTemplateTest {
|
|||||||
.doesNotContain("dashboard-workflow")
|
.doesNotContain("dashboard-workflow")
|
||||||
.doesNotContain(">01<", ">02<");
|
.doesNotContain(">01<", ">02<");
|
||||||
assertThat(layout)
|
assertThat(layout)
|
||||||
.contains("HMM HR Access Console")
|
.contains("Data & AI Backoffice")
|
||||||
.contains("사용자")
|
.contains("사용자")
|
||||||
.contains("접근 그룹")
|
.contains("접근 그룹")
|
||||||
.contains("역할")
|
.contains("역할")
|
||||||
@@ -73,7 +73,7 @@ class GuidedFlowTemplateTest {
|
|||||||
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
|
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
|
||||||
assertThat(template("login.html"))
|
assertThat(template("login.html"))
|
||||||
.contains("name=\"remember-me\"")
|
.contains("name=\"remember-me\"")
|
||||||
.contains("데이터 접근 제어 콘솔")
|
.contains("product?.displayName()")
|
||||||
.contains("<span class=\"login-mark\">접근</span>")
|
.contains("<span class=\"login-mark\">접근</span>")
|
||||||
.contains("로그인 유지")
|
.contains("로그인 유지")
|
||||||
.contains("th:if=\"${rememberMeAvailable}\"")
|
.contains("th:if=\"${rememberMeAvailable}\"")
|
||||||
@@ -265,11 +265,14 @@ class GuidedFlowTemplateTest {
|
|||||||
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
|
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
|
||||||
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
|
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
|
||||||
assertThat(sse)
|
assertThat(sse)
|
||||||
.contains("Instruction / parameter mapping")
|
.contains("tools/list.inputSchema")
|
||||||
.contains("resolve_hr_term")
|
.contains("환경 설정으로 승인한 MCP 도구")
|
||||||
.contains("search_hr_data")
|
.doesNotContain(
|
||||||
.contains("search_hr_policy")
|
"resolve_hr_term",
|
||||||
.doesNotContain("kb_select_ai_vpd", "KB_CLAIMS");
|
"search_hr_data",
|
||||||
|
"search_hr_policy",
|
||||||
|
"kb_select_ai_vpd",
|
||||||
|
"KB_CLAIMS");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class SettingsTemplateRenderTest {
|
|||||||
String database = engine.process("settings-database", context);
|
String database = engine.process("settings-database", context);
|
||||||
|
|
||||||
assertThat(connection)
|
assertThat(connection)
|
||||||
.contains("HMM HR Agent 도구")
|
.contains("설정 기반 MCP 도구")
|
||||||
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
|
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
|
||||||
.contains("Legacy ORDS Base URL")
|
.contains("Legacy ORDS Base URL")
|
||||||
.contains("/settings/database")
|
.contains("/settings/database")
|
||||||
|
|||||||
Reference in New Issue
Block a user