refs #703 #704: finalize smilegate game data poc

This commit is contained in:
devmrko
2026-07-22 15:35:02 +09:00
parent 20c6a82338
commit 211dd2b2c8
42 changed files with 576 additions and 445 deletions

View File

@@ -51,6 +51,13 @@ 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=""
# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다.
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_DB_USERNAME="SGMP_POC"
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_HAIKU45"
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- # --- (2c) 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

4
.gitignore vendored
View File

@@ -17,6 +17,10 @@ logs/
# Java / Maven # Java / Maven
target/ target/
# Python / Streamlit
__pycache__/
*.py[cod]
# Locally downloaded development tools (for example SQLcl) # Locally downloaded development tools (for example SQLcl)
.tools/ .tools/

View File

@@ -0,0 +1,76 @@
# 설계서: 스마일게이트 백오피스 잔여 UI 전환 및 운영 검증
## 추적성
- Redmine: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
- 관련 설계: `docs/design/smilegate-demo-rebranding/README.md`, `docs/design/smilegate-identity-administration/README.md`
- 구현 대상: `src/main/resources/templates/`, `src/main/resources/static/js/app.js`, `src/main/java/com/cloudhandson/vpdbackoffice/`, `poc4_active_source_20260714/`
- 검증 대상: Maven·Streamlit 설정 테스트, 인증 후 핵심 메뉴 HTTP 응답, 화면의 잔여 고객사 문구 검사
- 상태: Implemented / deployment pending
## 프로젝트 개요
이 저장소의 Spring Boot 백오피스는 Oracle VPD, Data Redaction, FGA, ORDS 및 Select AI PoC의 운영 설정을 확인하고 관리한다. 공개 데모의 고객·업무 대상은 스마일게이트 게임 로그와 서비스 데이터 분석이다.
## 목표
백오피스에 남은 KB손해보험/보험 업무 예시를 스마일게이트 게임 데이터 기준으로 전환한다. 화면 문구만 바꾸지 않고, 실제 MCP Select AI 안내·행 접근 규칙 요약·마스킹 동기화 대상도 `SGMP_POC` 게임 데이터와 모순되지 않게 맞춘다.
## 범위
1. 웹 화면과 브라우저에서 실행되는 JavaScript에 노출된 기존 보험 업무 예시를 게임 사용자·게임 서비스·판매/환불 데이터 예시로 교체한다.
2. 권한 규칙의 표시명과 미리보기는 기존 조건 코드의 저장 형식을 보존하면서 게임 데이터 의미로 설명한다.
3. MCP 데모의 tool 식별자·설명·질의 예시를 `SGMP_POC` Select AI 프로파일 기반으로 전환한다. 행 접근 토큰을 전제로 하는 기존 KB ORDS endpoint를 게임 데이터 endpoint인 것처럼 표시하지 않는다.
4. Data Redaction 동기화는 `SGMP_POC`의 실제 게임 사용자·판매 데이터 컬럼만 관리 대상으로 삼는다.
5. 내부 호환용 `CB_*` 뷰와 과거 SQL 이력은 실행 경로에서 제외한다. Smilegate 공개 화면·MCP 설정은 이력의 고객 데이터나 endpoint를 참조하지 않는다.
6. Streamlit 외피는 Smilegate 프로필·게임 데이터 시나리오·`oracle.select_ai.smilegate_game_text2sql` MCP 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
## 설계 결정
### 1. 업무 용어는 데이터 모델의 사실에 맞춘다
- 사용자 식별자: `CZN_COMN_USER_MST.GUID`/`AUID`, `COMN_SALES_USER_MST.USER_KEY_VAL`
- 게임 서비스 식별: `COMN_GAME_ALIAS_BAS``GAME_ID`, `GAME_PREFIX`, `GAME_NM`, `GAME_ALIAS_NM`
- 거래/서비스 데이터: `COMN_SALES_TXN`, `COMN_REFUND_TXN`, `CZN_CUSTOM_*`
화면 예시는 위 객체를 사용하되, 실제로 존재하지 않는 담당자·채널 컬럼을 SQL 예시로 만들지 않는다.
### 2. 조건 코드의 호환성과 표시 의미를 분리한다
`OWN_CONTRACT`, `CHANNEL_CONTRACT`, `OWN_CUSTOMER`, `CHANNEL_CUSTOMER` 같은 과거 코드값은 저장값 호환을 위해 유지한다. 화면에는 각각 `담당 게임 서비스`, `토큰 채널 게임 서비스`, `담당 게임 사용자 데이터`, `토큰 채널 게임 사용자 데이터`로 표시한다. VPD 구현이 게임 데이터에 대한 실제 관계를 갖지 않는 조건은 설명에서 일반적인 보안 범위 조건으로만 제시하고, 존재하지 않는 조인 SQL을 제안하지 않는다.
### 3. MCP/Select AI는 현재 실행 경계를 정직하게 표시한다
MCP tool은 `SGMP_POC_HAIKU45` 프로파일을 기준으로 게임 데이터의 읽기 전용 `SELECT`/`WITH` 질의를 **생성**하는 용도로 안내한다. 생성 단계는 `SHOWSQL`만 사용하며 모델이 만든 SQL을 백오피스가 자동 실행하지 않는다. 운영자는 Database Actions 또는 검증된 실행 경로에서 SQL을 검토·실행한다.
프로파일은 `SGMP_POC` 소유이므로 일반 백오피스 관리 DB 연결(ADMIN)에서 사용할 수 없다. MCP Text2SQL 서비스는 별도 `BACKOFFICE_SELECT_AI_DB_URL`, `BACKOFFICE_SELECT_AI_DB_USERNAME`, `BACKOFFICE_SELECT_AI_DB_PASSWORD` 환경 변수로 `SGMP_POC` 연결을 만들고, 설정이 없을 때는 명확한 설정 오류만 반환한다. 비밀 값은 Git·화면·로그에 저장하지 않는다.
호출 전에 백오피스의 Bearer 토큰 해시를 검증하고 활성 사용자 토큰에만 Text2SQL 요청을 허용한다. 현재 PoC의 두 데모 운영 사용자는 게임 데이터 전체 권한을 갖지만, 후속 권한 세분화 시 이 지점에 역할별 데이터 범위 검증을 추가한다.
### 4. 마스킹 대상은 관리 가능한 실제 객체로 제한한다
마스킹 동기화 대상 owner는 `SGMP_POC`다. 관리 정책은 실제 컬럼 존재 여부를 검증한 뒤 사용자 식별자와 거래 사용자 식별자에만 적용한다. 대상에 없는 규칙은 DBMS_REDACT 호출 전에 화면 설정 오류로 처리한다.
## 변경 파일과 책임
| 영역 | 파일 | 변경 |
| --- | --- | --- |
| 행 접근 화면 | `templates/permissions.html`, `static/js/app.js`, `PermissionView.java` | 보험 용어와 존재하지 않는 KB SQL 예시 제거 |
| 마스킹 화면 | `templates/masking-rules.html`, `templates/user-masking-rules.html`, `MaskingPolicySynchronizer.java` | 게임 데이터 예시 및 실제 `SGMP_POC` 관리 대상 사용 |
| VPD/운영 화면 | `templates/vpd-filter-runtime.html`, `templates/operation-status.html` | 게임 데이터 상태 표시 예시 적용 |
| MCP 화면 | `templates/mcp-sse.html`, `McpSseService.java`, `SmilegateSelectAiService.java` | 게임 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 |
| 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 |
| Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 |
## 완료 기준
1. Smilegate 공개 화면·활성 MCP 설정에서 기존 고객사명·보험 원장·기존 endpoint가 검색되지 않는다. 과거 SQL 이력 및 미실행 호환 코드는 제외한다.
2. `SGMP_POC` 게임 데이터 객체만 마스킹 동기화 대상으로 선택된다.
3. `mvn test`가 통과한다.
4. 인증된 `admin`으로 주요 메뉴가 오류 배너 없이 200 응답을 반환하고, Streamlit의 MCP는 Text2SQL 생성 결과를 정상 표기한다.
5. 변경 사항은 #703을 참조하는 Git 커밋과 Redmine 작업 로그로 남긴다.
## 위험 및 완화
- 과거 KB ORDS API는 게임 데이터 정책을 보장하지 않는다. endpoint 이름만 치환해 기존 API를 재사용하지 않는다.
- 운영 VM SSH 키 인증이 거부될 수 있다. 로컬 빌드·공개 URL 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.

View File

@@ -0,0 +1,43 @@
# 설계서: Smilegate 전용 브랜치 분리
## 추적성
- Redmine: #704 `[Release] Smilegate 전용 브랜치 분리`
- 관련 이슈: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
- 작업 경로: `/Users/joungminko/claude-workspace/vpd-smilegate-rebrand`
- 원격: `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git`
- 상태: Draft
## 프로젝트 개요
`vpd-permission-poc`은 Spring Boot VPD 관리 백오피스를 포함한다. HMM과 Smilegate 데모는 현재 같은 원격 저장소를 사용하지만, 고객별 화면·데이터 모델·배포 기준은 분리돼야 한다.
## 목표
Smilegate 작업본을 원격 `smilegate` 브랜치로 분리한다. HMM은 기존 `main` 브랜치를 그대로 사용하고, Smilegate 변경은 `smilegate` 브랜치만 기준으로 커밋·푸시·배포한다.
## 범위
1. detached HEAD 상태의 Smilegate worktree에서 `smilegate` 브랜치를 생성한다.
2. `origin/smilegate`를 생성하고 현재 worktree의 upstream으로 설정한다.
3. #703의 Smilegate 전용 설계서와 UI 변경만 `smilegate` 브랜치에 기록한다.
4. HMM 작업본, `origin/main`, 다른 worktree의 파일과 HEAD를 변경하지 않는다.
## 비범위
- HMM의 로컬 수정·브랜치·배포 변경
- 기존 `main`의 이력 재작성 또는 강제 푸시
- 원격 저장소를 새로 생성하거나 삭제하는 작업
## 검증 기준
1. `git branch --show-current`은 Smilegate worktree에서 `smilegate`를 반환한다.
2. `git rev-parse --abbrev-ref @{u}``origin/smilegate`를 반환한다.
3. `origin/main`의 커밋 ID는 분리 전후 동일하다.
4. HMM 작업본의 status와 HEAD는 분리 작업으로 변경되지 않는다.
## 운영 규칙
- Smilegate 배포는 `origin/smilegate`의 검증된 커밋만 사용한다.
- HMM 변경은 `main` 또는 HMM 전용 작업 경로에서만 수행한다.
- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.

View File

@@ -62,7 +62,7 @@ from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25" MCP_PROTOCOL_VERSION = "2025-11-25"
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd" PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql"
DEFAULT_QUESTION = "" DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000 MAX_RESPONSE_BYTES = 1_000_000
MAX_CONVERSATION_MESSAGES = 8 MAX_CONVERSATION_MESSAGES = 8
@@ -77,17 +77,21 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
ENV_FILE = ROOT / ".env" ENV_FILE = ROOT / ".env"
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json" MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json" VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json" DEMO_SCENARIOS_FILE = ROOT / "config" / "smilegate_demo_scenarios.json"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json" APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
DEFAULT_VPD_USER_ID = "FC00789" DEFAULT_VPD_USER_ID = "sg-teamlead"
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
AUDIT_SCHEMA = "POC_2" AUDIT_SCHEMA = "SGMP_POC"
REFERENCE_EVIDENCE_ENABLED = (
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
in {"1", "true", "yes"}
)
AUDIT_DB_ENV_FILE = Path( AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env") os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
).expanduser() ).expanduser()
@@ -2374,6 +2378,8 @@ def collect_security_evidence(
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Collect predefined, token-scoped security evidence without exposing secrets.""" """Collect predefined, token-scoped security evidence without exposing secrets."""
if not REFERENCE_EVIDENCE_ENABLED:
return {}
kind = _security_evidence_kind(question) kind = _security_evidence_kind(question)
if not kind or token_preset is None: if not kind or token_preset is None:
return {} return {}
@@ -2938,6 +2944,8 @@ def collect_business_evidence(
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Collect token-validated, explicitly scoped business and catalog evidence.""" """Collect token-validated, explicitly scoped business and catalog evidence."""
if not REFERENCE_EVIDENCE_ENABLED:
return {}
kind = _business_evidence_kind(question) kind = _business_evidence_kind(question)
if not kind or token_preset is None: if not kind or token_preset is None:
return {} return {}
@@ -7069,8 +7077,8 @@ def main() -> None:
else: else:
selected_token_preset = None selected_token_preset = None
if configured_mcp_bearer: if configured_mcp_bearer:
# The HMM MCP gateway uses its own server-side credential. VPD user # The Smilegate MCP gateway uses a server-side credential. UI-only
# tokens remain UI context only and must never be forwarded to it. # context must never be forwarded as a second bearer token.
st.caption("MCP 인증: 서버 관리 토큰 적용") st.caption("MCP 인증: 서버 관리 토큰 적용")
manual_bearer_token = "" manual_bearer_token = ""
elif selected_token_preset is not None: elif selected_token_preset is not None:
@@ -7256,9 +7264,7 @@ def main() -> None:
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>', '<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
unsafe_allow_html=True, unsafe_allow_html=True,
) )
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs( architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"])
["아키텍처", "시나리오", "감사로그", "보안관리"]
)
with architecture_tab: with architecture_tab:
_render_architecture_tab() _render_architecture_tab()
@@ -7359,12 +7365,6 @@ def main() -> None:
height=0, height=0,
) )
with audit_tab:
_render_fga_audit_tab()
with operations_tab:
_render_vpd_operations_tab()
if not submitted: if not submitted:
return return

View File

@@ -1,21 +1,21 @@
{ {
"version": 1, "version": 1,
"product": { "product": {
"name": "AI 업무 에이전트", "name": "SMILEGATE DATA & AI POC",
"short_name": "AGENT", "short_name": "SMILEGATE",
"page_title": "AI 업무 에이전트", "page_title": "SMILEGATE DATA & AI POC",
"page_icon": "🤖", "page_icon": "🤖",
"header_title": "AI 업무 에이전트", "header_title": "스마일게이트 게임 데이터 AI 에이전트",
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.", "header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
"login_kicker": "DATA & AI DEMO", "login_kicker": "SMILEGATE DATA & AI POC",
"login_title": "AI 업무 에이전트", "login_title": "스마일게이트 게임 데이터 AI 에이전트",
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.", "login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
"login_footer": "인된 DEMO 사용자만 접근할 수 있습니다." "login_footer": "인된 Data & AI PoC 사용자만 접근할 수 있습니다."
}, },
"theme": { "theme": {
"primary_color": "#003b70", "primary_color": "#113F67",
"text_color": "#172b3a", "text_color": "#15283B",
"muted_color": "#667785", "muted_color": "#5D6C7C",
"border_color": "#dfe7ed" "border_color": "#D7E0E8"
} }
} }

View File

@@ -1,22 +1,20 @@
{ {
"default_server_id": "hmm_hr_mcp", "default_server_id": "smilegate_game_data_mcp",
"servers": [ "servers": [
{ {
"id": "hmm_hr_mcp", "id": "smilegate_game_data_mcp",
"enabled": true, "enabled": true,
"provider": "hmm_compat_mcp", "provider": "smilegate_select_ai_mcp",
"transport": "http", "transport": "http",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", "endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN", "auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
"default_tool": "search_hr_data", "default_tool": "oracle.select_ai.smilegate_game_text2sql",
"router_model_profile": "gpt54_mini_oci", "router_model_profile": "gpt54_mini_oci",
"tool_allowlist": [ "tool_allowlist": [
"search_hr_data", "oracle.select_ai.smilegate_game_text2sql"
"resolve_hr_term",
"search_hr_policy"
], ],
"description": "HMM HR knowledge and data MCP server" "description": "Smilegate game-data Text2SQL MCP server"
} }
] ]
} }

View File

@@ -0,0 +1,41 @@
{
"version": 1,
"description": "Smilegate Data & AI PoC 화면에 표시할 게임 데이터 질의 샘플입니다.",
"scenarios": [
{
"id": "GAME-01",
"enabled": true,
"category": "활성 사용자",
"title": "카제나 최신 AU",
"question": "카제나 최신 기준 활성 사용자 수(AU)를 알려줘"
},
{
"id": "GAME-02",
"enabled": true,
"category": "매출",
"title": "게임별 판매 현황",
"question": "최신 기준 게임별 판매 건수와 판매 금액을 보여줘"
},
{
"id": "GAME-03",
"enabled": true,
"category": "환불",
"title": "최근 환불 현황",
"question": "최신 기준 게임별 환불 건수와 환불 금액을 보여줘"
},
{
"id": "GAME-04",
"enabled": true,
"category": "게임·서버",
"title": "게임 서버 구성",
"question": "등록된 게임과 게임 서버 정보를 보여줘"
},
{
"id": "GAME-05",
"enabled": true,
"category": "사용자 분석",
"title": "신규 사용자 현황",
"question": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
}
]
}

View File

@@ -1,7 +1,7 @@
{ {
"presets": [ "presets": [
{ {
"enabled": true, "enabled": false,
"default": true, "default": true,
"token": "vpd_live_REPLACE_WITH_USER_TOKEN", "token": "vpd_live_REPLACE_WITH_USER_TOKEN",
"user_id": "FC00789", "user_id": "FC00789",

View File

@@ -16,29 +16,29 @@ class DemoScenarioConfigTest(unittest.TestCase):
with patch.dict( with patch.dict(
"os.environ", "os.environ",
{ {
"AGENT_CONSOLE_SHORT_NAME": "HMM", "AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트", "AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70", "AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
}, },
clear=False, clear=False,
): ):
profile = load_app_profile(path) profile = load_app_profile(path)
self.assertEqual(profile.short_name, "HMM") self.assertEqual(profile.short_name, "SMILEGATE")
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트") self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
self.assertEqual(profile.primary_color, "#003b70") self.assertEqual(profile.primary_color, "#113F67")
def test_profile_reads_dotenv_values(self) -> None: def test_profile_reads_dotenv_values(self) -> None:
path = Path(__file__).parents[1] / "config" / "app_profile.json" path = Path(__file__).parents[1] / "config" / "app_profile.json"
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
env_file = Path(temp_dir) / ".env" env_file = Path(temp_dir) / ".env"
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8") env_file.write_text("AGENT_CONSOLE_SHORT_NAME=SMILEGATE\n", encoding="utf-8")
profile = load_app_profile(path, env_file) profile = load_app_profile(path, env_file)
self.assertEqual(profile.short_name, "HMM") self.assertEqual(profile.short_name, "SMILEGATE")
def test_hmm_scenarios_are_enabled_and_unique(self) -> None: def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
scenarios = load_demo_scenarios(path) scenarios = load_demo_scenarios(path)
self.assertGreaterEqual(len(scenarios), 3) self.assertGreaterEqual(len(scenarios), 3)

View File

@@ -98,11 +98,8 @@
<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>70_sg_tool_user.sql</include>
<include>63_kb_aso_masking_rule_runtime.sql</include> <include>71_sg_identity_administration.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>

View File

@@ -8,7 +8,8 @@ public record BackofficeProperties(
Security security, Security security,
Token token, Token token,
Ords ords, Ords ords,
Ai ai Ai ai,
SelectAi selectAi
) { ) {
public record Security( public record Security(
@@ -72,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 SGMP_POC. */
public record SelectAi(
String dbUrl,
String dbUsername,
String dbPassword,
String profile
) {
public boolean configured() {
return dbUrl != null && !dbUrl.isBlank()
&& dbUsername != null && !dbUsername.isBlank()
&& dbPassword != null && !dbPassword.isBlank()
&& profile != null && !profile.isBlank();
}
}
} }

View File

@@ -51,6 +51,6 @@ public class DbPoolWarmup {
groupService.findGroupRoles(); groupService.findGroupRoles();
permissionService.findRoles(); permissionService.findRoles();
permissionService.findPermissionViews(); permissionService.findPermissionViews();
log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000); log.info("Smilegate identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
} }
} }

View File

@@ -27,10 +27,10 @@ public enum MaskingTemplate {
"A******** (예시)"), "A******** (예시)"),
RRN_PARTIAL( RRN_PARTIAL(
"RRN_PARTIAL", "RRN_PARTIAL",
"주민등록번호 부분 마스킹", "식별번호 부분 마스킹",
"앞 6자리만 표시하고 나머지 가리는 사전 정의 식별번호 규칙입니다.", "앞 6자리만 표시하고 나머지 가리는 사전 정의 식별번호 규칙입니다.",
"DBMS_REDACT.REGEXP", "DBMS_REDACT.REGEXP",
"900101-******* (예시)"); "123456-******* (예시)");
private final String code; private final String code;
private final String label; private final String label;

View File

@@ -50,16 +50,16 @@ public record PermissionView(
return "토큰으로 식별된 이해관계자 본인 행"; return "토큰으로 식별된 이해관계자 본인 행";
} }
if (upper.contains(" OWN_CONTRACT")) { if (upper.contains(" OWN_CONTRACT")) {
return "담당 설계사 본인 계약"; return "담당 게임 서비스 범위";
} }
if (upper.contains(" CHANNEL_CONTRACT")) { if (upper.contains(" CHANNEL_CONTRACT")) {
return "토큰 사용자의 채널 계약"; return "토큰 채널 게임 서비스 범위";
} }
if (upper.contains(" OWN_CUSTOMER")) { if (upper.contains(" OWN_CUSTOMER")) {
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유"; return "담당 게임 사용자·거래 데이터 범위";
} }
if (upper.contains(" CHANNEL_CUSTOMER")) { if (upper.contains(" CHANNEL_CUSTOMER")) {
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유"; return "토큰 채널 게임 사용자·거래 데이터 범위";
} }
if (upper.contains(" STATIC_SQL ")) { if (upper.contains(" STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", ""); return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");

View File

@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"), "문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL", new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"), "첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL", new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식") "앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
); );
private static final String MASKING_RULE_SEED_SQL = """ private static final String MASKING_RULE_SEED_SQL = """
@@ -705,7 +705,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql @sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql @sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql @sql/adb/71_sg_identity_administration.sql
@sql/adb/21_agent_ords_security_ords_enable_schema.sql @sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속 -- 2. ORDS parsing schema로 접속
@@ -717,9 +717,8 @@ public class BackofficeSchemaService {
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords; GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행 -- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
@sql/adb/64_kb_aso_masking_default_column_rules.sql -- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
@sql/adb/63_kb_aso_masking_rule_runtime.sql
""".formatted(owner.toLowerCase()); """.formatted(owner.toLowerCase());
} }

View File

@@ -24,7 +24,7 @@ import org.springframework.stereotype.Service;
@Service @Service
public class MaskingPolicySynchronizer { public class MaskingPolicySynchronizer {
private static final String OWNER = "POC_2"; private static final String OWNER = "SGMP_POC";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap(); private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
@@ -38,10 +38,10 @@ public class MaskingPolicySynchronizer {
private static Map<String, String> managedPolicyMap() { private static Map<String, String> managedPolicyMap() {
Map<String, String> policies = new LinkedHashMap<>(); Map<String, String> policies = new LinkedHashMap<>();
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT"); policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT"); policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT"); policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT"); policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
return Collections.unmodifiableMap(policies); return Collections.unmodifiableMap(policies);
} }

View File

@@ -55,7 +55,7 @@ public class MaskingRuleService {
return mapper.findColumnRules(); return mapper.findColumnRules();
} }
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */ /** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() { public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses(); return mapper.findPolicyStatuses();
} }

View File

@@ -8,30 +8,29 @@ 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;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */ /** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */
@Service @Service
public class McpSseService { public class McpSseService {
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd"; private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query"; private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
private static final String SELECT_AI_VPD_QUERY_PROFILE = private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView( private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL, SELECT_AI_VPD_QUERY_TOOL,
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.", "SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.",
-1L, -1L,
"KB Select AI 행 접근 자연어 조회", "Smilegate 게임 데이터 Text2SQL",
SELECT_AI_VPD_QUERY_PATH SELECT_AI_VPD_QUERY_PATH
); );
private final SelectAiAgentOrdsService selectAiAgentOrdsService; private final SmilegateSelectAiService smilegateSelectAiService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public McpSseService( public McpSseService(
SelectAiAgentOrdsService selectAiAgentOrdsService, SmilegateSelectAiService smilegateSelectAiService,
ObjectMapper objectMapper ObjectMapper objectMapper
) { ) {
this.selectAiAgentOrdsService = selectAiAgentOrdsService; this.smilegateSelectAiService = smilegateSelectAiService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -106,17 +105,10 @@ public class McpSseService {
ObjectNode prompt = objectMapper.createObjectNode(); ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string"); prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다."); prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000); prompt.put("maxLength", 4000);
properties.set("prompt", prompt); properties.set("prompt", prompt);
ObjectNode limit = objectMapper.createObjectNode();
limit.put("type", "integer");
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
limit.put("minimum", 1);
limit.put("maximum", 100);
properties.set("limit", limit);
schema.set("properties", properties); schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode(); ArrayNode required = objectMapper.createArrayNode();
required.add("prompt"); required.add("prompt");
@@ -139,11 +131,7 @@ public class McpSseService {
} }
JsonNode response; JsonNode response;
try { try {
response = selectAiAgentOrdsService.run( response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText(""));
token,
arguments.path("prompt").asText(""),
normalizeLimit(arguments.path("limit").asInt(50))
);
} catch (VpdTokenAccessDeniedException ignored) { } catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult(); return tokenAccessDeniedResult();
} }
@@ -181,13 +169,6 @@ public class McpSseService {
return result; return result;
} }
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;
}
return Math.min(limit, 100);
}
private String pretty(Object value) { private String pretty(Object value) {
try { try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);

View File

@@ -643,7 +643,7 @@ public class OrdsProbeService {
} }
} }
} }
// APEX_JSON omits a ref-cursor column whose value is NULL. The KB // APEX_JSON omits a ref-cursor column whose value is NULL. The
// object handlers always select every registered protected column, so // object handlers always select every registered protected column, so
// a sensitive column missing from every JSON row is also a NULL result // a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller. // and must be reported as masked to the MCP/UI caller.

View File

@@ -19,39 +19,18 @@ public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of( private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
new ScriptDefinition( new ScriptDefinition(
"aso-masking-metadata", "smilegate-tool-users",
"ASO / 마스킹", "Smilegate 사용자",
"62_kb_aso_masking_backoffice_metadata.sql", "70_sg_tool_user.sql",
"컬럼 마스킹 규칙 메타데이터", "PoC 도구 사용자 초기 데이터",
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다." "Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
), ),
new ScriptDefinition( new ScriptDefinition(
"aso-masking-runtime", "smilegate-identity-administration",
"ASO / 마스킹", "Smilegate 권한",
"63_kb_aso_masking_rule_runtime.sql", "71_sg_identity_administration.sql",
"ASO 마스킹 런타임 적용", "사용자·그룹·역할 관리 모델",
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다." "Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
),
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를 노출합니다."
) )
); );

View File

@@ -1,137 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.util.UUID;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
@Service
public class SelectAiAgentOrdsService {
static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_LIMIT = 100;
private final SettingService settingService;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public SelectAiAgentOrdsService(
SettingService settingService,
RestTemplate ordsAgentRestTemplate,
ObjectMapper objectMapper
) {
this.settingService = settingService;
this.restTemplate = ordsAgentRestTemplate;
this.objectMapper = objectMapper;
}
/**
* Compatibility overload for callers of the former SQL-generation tool.
* The row-access query endpoint is stateless and therefore ignores conversationId.
*/
public JsonNode run(String bearerToken, String prompt, String conversationId) {
return run(bearerToken, prompt, 50);
}
public JsonNode run(String bearerToken, String prompt, int limit) {
String normalizedToken = required(bearerToken, "bearerToken");
String normalizedPrompt = required(prompt, "prompt");
if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
int normalizedLimit = normalizeLimit(limit);
String baseUrl = settingService.ordsBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
throw new AppException("ORDS base URL이 설정되지 않았습니다.");
}
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("prompt", normalizedPrompt);
requestBody.put("limit", normalizedLimit);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(normalizedToken);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString());
try {
ResponseEntity<String> response = restTemplate.exchange(
endpoint(baseUrl),
HttpMethod.POST,
new HttpEntity<>(requestBody.toString(), headers),
String.class
);
JsonNode body = parse(response.getBody());
if (body.hasNonNull("error")) {
throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
}
return body;
} catch (HttpStatusCodeException e) {
if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
|| e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
throw new VpdTokenAccessDeniedException();
}
throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
+ ": " + responseError(e.getResponseBodyAsString()));
} catch (ResourceAccessException e) {
throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
}
}
private URI endpoint(String baseUrl) {
return UriComponentsBuilder.fromUriString(baseUrl)
.path(ORDS_PATH)
.build()
.toUri();
}
private JsonNode parse(String value) {
try {
if (value == null || value.isBlank()) {
throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
}
return objectMapper.readTree(value);
} catch (AppException e) {
throw e;
} catch (Exception e) {
throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
}
}
private String responseError(String body) {
try {
JsonNode parsed = objectMapper.readTree(body);
return parsed.path("error").asText(body == null ? "" : body);
} catch (Exception ignored) {
return body == null ? "" : body;
}
}
private String required(String value, String name) {
if (value == null || value.isBlank()) {
throw new AppException(name + "은(는) 필수입니다.");
}
return value.trim();
}
private int normalizeLimit(int value) {
if (value < 1) {
return 50;
}
return Math.min(value, MAX_LIMIT);
}
}

View File

@@ -0,0 +1,125 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.stereotype.Service;
/**
* Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile.
* The generated SQL is never executed by this service.
*/
@Service
public class SmilegateSelectAiService {
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final String ACTION_SHOWSQL = "showsql";
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
private final ObjectMapper objectMapper;
public SmilegateSelectAiService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock,
ObjectMapper objectMapper
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
this.objectMapper = objectMapper;
}
public JsonNode generateShowSql(String bearerToken, String prompt) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
String generatedSql = generate(selectAi, normalizedPrompt);
String normalizedSql = validateReadOnlySql(generatedSql);
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWSQL");
response.put("profile", selectAi.profile());
response.put("generatedSql", normalizedSql);
response.put("execution", "NOT_EXECUTED");
response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요.");
return response;
}
private void requireActiveToken(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank()) {
throw new VpdTokenAccessDeniedException();
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now)) {
throw new VpdTokenAccessDeniedException();
}
}
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("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
}
}
private 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 문장이 포함되어 있어 반환하지 않습니다.");
}
return normalized;
}
}

View File

@@ -68,3 +68,10 @@ 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:
# Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
# not the ADMIN connection used by the backoffice control plane.
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:SGMP_POC_HAIKU45}

View File

@@ -3,9 +3,10 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent"> <insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
INSERT INTO hmm_access_audit ( INSERT INTO sg_audit_event (
event_type, key_id, object_id, status, row_count, error_code, message, created_at audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
) VALUES ( ) VALUES (
sg_audit_event_seq.NEXTVAL,
#{eventType,jdbcType=VARCHAR}, #{eventType,jdbcType=VARCHAR},
#{keyId,jdbcType=NUMERIC}, #{keyId,jdbcType=NUMERIC},
#{objectId,jdbcType=NUMERIC}, #{objectId,jdbcType=NUMERIC},

View File

@@ -68,10 +68,10 @@
--> -->
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus"> <select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
WITH managed_policy AS ( WITH managed_policy AS (
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual SELECT 'CZN_COMN_USER_MST' AS object_name, 'SG_CZN_USER_REDACT' AS policy_name FROM dual
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual UNION ALL SELECT 'COMN_SALES_USER_MST', 'SG_SALES_USER_REDACT' FROM dual
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual UNION ALL SELECT 'COMN_SALES_TXN', 'SG_SALES_TXN_REDACT' FROM dual
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual UNION ALL SELECT 'COMN_REFUND_TXN', 'SG_REFUND_TXN_REDACT' FROM dual
), ),
configured AS ( configured AS (
SELECT protected_object.object_name, SELECT protected_object.object_name,
@@ -80,7 +80,7 @@
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2' WHERE protected_object.owner = 'SGMP_POC'
AND rule.enabled_yn = 'Y' AND rule.enabled_yn = 'Y'
GROUP BY protected_object.object_name GROUP BY protected_object.object_name
), ),
@@ -96,7 +96,7 @@
LEFT JOIN redaction_columns policy_column LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = policy.object_owner ON policy_column.object_owner = policy.object_owner
AND policy_column.object_name = policy.object_name AND policy_column.object_name = policy.object_name
WHERE policy.object_owner = 'POC_2' WHERE policy.object_owner = 'SGMP_POC'
GROUP BY policy.object_name, policy.policy_name, policy.enable GROUP BY policy.object_name, policy.policy_name, policy.enable
), ),
missing_columns AS ( missing_columns AS (
@@ -110,7 +110,7 @@
ON policy_column.object_owner = protected_object.owner ON policy_column.object_owner = protected_object.owner
AND policy_column.object_name = protected_object.object_name AND policy_column.object_name = protected_object.object_name
AND policy_column.column_name = protected_column.column_name AND policy_column.column_name = protected_column.column_name
WHERE protected_object.owner = 'POC_2' WHERE protected_object.owner = 'SGMP_POC'
AND rule.enabled_yn = 'Y' AND rule.enabled_yn = 'Y'
AND policy_column.column_name IS NULL AND policy_column.column_name IS NULL
GROUP BY protected_object.object_name GROUP BY protected_object.object_name
@@ -120,14 +120,14 @@
COUNT(*) AS extra_column_count COUNT(*) AS extra_column_count
FROM redaction_columns policy_column FROM redaction_columns policy_column
JOIN managed_policy managed ON managed.object_name = policy_column.object_name JOIN managed_policy managed ON managed.object_name = policy_column.object_name
WHERE policy_column.object_owner = 'POC_2' WHERE policy_column.object_owner = 'SGMP_POC'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM sg_column_masking_rule link FROM sg_column_masking_rule link
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2' WHERE protected_object.owner = 'SGMP_POC'
AND protected_object.object_name = policy_column.object_name AND protected_object.object_name = policy_column.object_name
AND protected_column.column_name = policy_column.column_name AND protected_column.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y' AND rule.enabled_yn = 'Y'
@@ -138,18 +138,15 @@
SELECT policy.object_name, SELECT policy.object_name,
COUNT(*) AS legacy_vpd_column_policy_count COUNT(*) AS legacy_vpd_column_policy_count
FROM all_policies policy FROM all_policies policy
WHERE policy.object_owner = 'POC_2' WHERE policy.object_owner = 'SGMP_POC'
AND policy.enable = 'YES' AND policy.enable = 'YES'
AND policy.policy_name IN ( AND policy.policy_name IN (
'KB_CUST_NM_CLS_POLICY', 'KB_RRN_CLS_POLICY', 'SG_CZN_USER_REDACT', 'SG_SALES_USER_REDACT',
'KB_PREMIUM_CLS_POLICY', 'SG_SALES_TXN_REDACT', 'SG_REFUND_TXN_REDACT'
'KB_CLAIM_AMT_CLS_POLICY', 'KB_PAID_AMT_CLS_POLICY',
'KB_EXT_INSURER_CLS_POLICY', 'KB_EXT_PRODUCT_GRP_CLS_POLICY',
'KB_EXT_PRODUCT_TYPE_CLS_POLICY'
) )
GROUP BY policy.object_name GROUP BY policy.object_name
) )
SELECT 'POC_2' AS owner, SELECT 'SGMP_POC' AS owner,
managed.object_name, managed.object_name,
managed.policy_name, managed.policy_name,
database_policy.enable AS enabled, database_policy.enable AS enabled,

View File

@@ -3,31 +3,36 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject"> <select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
SELECT s.user_id AS stakeholder_user_id, SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
s.user_nm AS username, u.user_name AS username,
s.role, COALESCE((
s.channel, SELECT MIN(r.role_name)
s.access_scope, FROM sg_user_role user_role
JOIN sg_app_role r ON r.role_id = user_role.role_id
WHERE user_role.user_id = u.user_id
), 'DATA_AI_OPERATOR') AS role,
'BACKOFFICE' AS channel,
u.dept_code AS access_scope,
u.user_id AS app_user_id u.user_id AS app_user_id
FROM poc_2.kb_stakeholders s FROM sg_app_user u
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
WHERE u.active = 'Y' WHERE u.active = 'Y'
ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END, ORDER BY role, u.dept_code, u.user_name, u.user_id
s.channel,
s.user_nm,
s.user_id
</select> </select>
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject"> <select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
SELECT s.user_id AS stakeholder_user_id, SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
s.user_nm AS username, u.user_name AS username,
s.role, COALESCE((
s.channel, SELECT MIN(r.role_name)
s.access_scope, FROM sg_user_role user_role
JOIN sg_app_role r ON r.role_id = user_role.role_id
WHERE user_role.user_id = u.user_id
), 'DATA_AI_OPERATOR') AS role,
'BACKOFFICE' AS channel,
u.dept_code AS access_scope,
u.user_id AS app_user_id u.user_id AS app_user_id
FROM poc_2.kb_stakeholders s FROM sg_app_user u
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id WHERE TO_CHAR(u.user_id) = #{stakeholderUserId,jdbcType=VARCHAR}
WHERE s.user_id = #{stakeholderUserId,jdbcType=VARCHAR}
AND u.active = 'Y' AND u.active = 'Y'
</select> </select>
</mapper> </mapper>

View File

@@ -728,16 +728,16 @@ function permissionRuleBusinessLabel(type, column, value) {
return `토큰 이해관계자 본인 행 (${displayColumn})`; return `토큰 이해관계자 본인 행 (${displayColumn})`;
} }
if (type === 'OWN_CONTRACT') { if (type === 'OWN_CONTRACT') {
return `담당 설계사 본인 계약 (${displayColumn})`; return `담당 게임 서비스 범위 (${displayColumn})`;
} }
if (type === 'CHANNEL_CONTRACT') { if (type === 'CHANNEL_CONTRACT') {
return `토큰 사용자 채널 계약 (${displayColumn})`; return `토큰 채널 게임 서비스 범위 (${displayColumn})`;
} }
if (type === 'OWN_CUSTOMER') { if (type === 'OWN_CUSTOMER') {
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`;
} }
if (type === 'CHANNEL_CUSTOMER') { if (type === 'CHANNEL_CUSTOMER') {
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`;
} }
if (type === 'STATIC_SQL') { if (type === 'STATIC_SQL') {
return `정적 SQL 조건: ${value || '조건식 미입력'}`; return `정적 SQL 조건: ${value || '조건식 미입력'}`;
@@ -813,10 +813,10 @@ function collectWizardPredicates(root) {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`; return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
} }
if (type === 'STAKEHOLDER_SELF') { if (type === 'STAKEHOLDER_SELF') {
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`; return '조건 코드 STAKEHOLDER_SELF: DB 행 접근 함수가 토큰 컨텍스트와 등록된 업무 관계를 기준으로 조건을 생성';
} }
if (type === 'STAKEHOLDER_CHANNEL') { if (type === 'STAKEHOLDER_CHANNEL') {
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`; return '조건 코드 STAKEHOLDER_CHANNEL: DB 행 접근 함수가 토큰 채널과 등록된 업무 관계를 기준으로 조건을 생성';
} }
if (type === 'TOKEN_SUBJECT') { if (type === 'TOKEN_SUBJECT') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`; return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
@@ -828,10 +828,10 @@ function collectWizardPredicates(root) {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`; return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
} }
if (type === 'OWN_CUSTOMER') { if (type === 'OWN_CUSTOMER') {
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'))`; return '조건 코드 OWN_CUSTOMER: DB 행 접근 함수가 담당 사용자와 게임 데이터 관계를 기준으로 조건을 생성';
} }
if (type === 'CHANNEL_CUSTOMER') { if (type === 'CHANNEL_CUSTOMER') {
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL'))`; return '조건 코드 CHANNEL_CUSTOMER: DB 행 접근 함수가 토큰 채널과 게임 데이터 관계를 기준으로 조건을 생성';
} }
if (type === 'DEPT' || type === 'EMP_NO') { if (type === 'DEPT' || type === 'EMP_NO') {
return `${displayColumn} = ${sqlLiteral(value)}`; return `${displayColumn} = ${sqlLiteral(value)}`;

View File

@@ -108,8 +108,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead> <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()}">POC_2.KB_CUSTOMERS</code></td> <td><code th:text="${status.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
<td><code th:text="${status.policyName()}">KB_CUSTOMER_PII_REDACT</code></td> <td><code th:text="${status.policyName()}">SG_GAME_USER_REDACT</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,17 +142,17 @@
<section class="content-band"> <section class="content-band">
<h2>컬럼 마스킹 규칙 등록</h2> <h2>컬럼 마스킹 규칙 등록</h2>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>KB_RRN_STANDARD</code>.</p> <p class="section-subtitle">게임 데이터 기준의 업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>GAME_USER_ID_MASK</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="KB_RRN_STANDARD"> <input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="GAME_USER_ID_MASK">
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span> <span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
</label> </label>
<label> <label>
규칙명 규칙명
<input class="form-control" name="ruleName" maxlength="100" required placeholder="주민번호 기본 마스킹"> <input class="form-control" name="ruleName" maxlength="100" required placeholder="게임 사용자 식별자 기본 마스킹">
</label> </label>
<label> <label>
마스킹 템플릿 마스킹 템플릿
@@ -176,8 +176,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="rule : ${rules}"> <tr th:each="rule : ${rules}">
<td><code th:text="${rule.ruleCode()}">KB_RRN_STANDARD</code></td> <td><code th:text="${rule.ruleCode()}">GAME_USER_ID_MASK</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>
<td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</span></td> <td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</span></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}">POC_2.KB_CONTRACTS.PREMIUM</option> th:text="${object.displayName() + '.' + columnName}">SGMP_POC.COMN_SALES_TXN.GUID</option>
</optgroup> </optgroup>
</th:block> </th:block>
</select> </select>
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>POC_2.KB_CONTRACTS.PREMIUM</code>.</span> <span class="form-hint">현재 관리 대상 ASO 정책이 있는 게임 데이터 객체만 표시됩니다. 예: <code>SGMP_POC.COMN_SALES_TXN.GUID</code>.</span>
</label> </label>
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button> <button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
</form> </form>
@@ -253,9 +253,9 @@
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead> <thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
<tbody> <tbody>
<tr th:each="columnRule : ${columnRules}"> <tr th:each="columnRule : ${columnRules}">
<td><code th:text="${columnRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td> <td><code th:text="${columnRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.USER_ID</code></td>
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td> <td th:text="${columnRule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</td> <td th:text="${columnRule.template().label()}">식별자 부분 마스킹</td>
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td> <td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}"> <td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
<span class="badge" <span class="badge"

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1> <h1>MCP 연동</h1>
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <summary>도움말</summary>
<p>사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하, DB에서는 토큰 기준 행 접근 context가 적용됩니다.</p> <p>사용자 Bearer Token을 검증한 뒤 <code>SGMP_POC_HAIKU45</code> Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하, 생성 SQL은 자동 실행하지 않습니다.</p>
</details> </details>
</section> </section>
@@ -99,7 +99,7 @@
<td> <td>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div> <div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted"> <small class="text-muted">
HTTP <code>Authorization</code>행 접근 컨텍스트 · <code>prompt</code>GPT-5.4-mini Select AI 자연어 질의 · <code>limit</code> → 최대 반환 행 수 HTTP <code>Authorization</code>활성 PoC 사용자 토큰 검증 · <code>prompt</code>게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
</small> </small>
</td> </td>
</tr> </tr>
@@ -116,10 +116,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">{
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.", "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
"limit": 50
}</pre> }</pre>
<p class="form-hint">등록 tool은 <code>ords.query.kb_select_ai_vpd</code> 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.</p> <p class="form-hint">등록 tool은 <code>oracle.select_ai.smilegate_game_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 <code>SGMP_POC_HAIKU45</code>가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
</details> </details>
</section> </section>
@@ -153,10 +152,9 @@
"id": 3, "id": 3,
"method": "tools/call", "method": "tools/call",
"params": { "params": {
"name": "ords.query.kb_select_ai_vpd", "name": "oracle.select_ai.smilegate_game_text2sql",
"arguments": { "arguments": {
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.", "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
"limit": 50
} }
} }
}</pre> }</pre>

View File

@@ -159,8 +159,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead> <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()}">POC_2.KB_CLAIMS</code></td> <td><code th:text="${status.targetLabel()}">SGMP_POC.COMN_SALES_TXN</code></td>
<td><code th:text="${status.policyName()}">KB_CLAIM_AMOUNT_REDACT</code></td> <td><code th:text="${status.policyName()}">SG_SALES_TXN_REDACT</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>

View File

@@ -9,7 +9,7 @@
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <summary>도움말</summary>
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p> <p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 주민번호, 보험료, 청구금액 같은 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p> <p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 게임 사용자 식별자, 결제금액 같은 민감 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
</details> </details>
</div> </div>
@@ -158,15 +158,15 @@
</select> </select>
<select class="form-select rule-type-select" name="ruleType"> <select class="form-select rule-type-select" name="ruleType">
<option value="ALL">ALL</option> <option value="ALL">ALL</option>
<optgroup label="KB · 본인 범위"> <optgroup label="게임 데이터 · 담당자 범위">
<option value="OWN_CONTRACT">본인 담당 계약</option> <option value="OWN_CONTRACT">담당 게임 서비스</option>
<option value="OWN_CUSTOMER">본인 담당 고객</option> <option value="OWN_CUSTOMER">담당 게임 사용자</option>
</optgroup> </optgroup>
<optgroup label="KB · 채널 범위"> <optgroup label="게임 데이터 · 채널 범위">
<option value="CHANNEL_CONTRACT">토큰 채널 계약</option> <option value="CHANNEL_CONTRACT">토큰 채널 게임 서비스</option>
<option value="CHANNEL_CUSTOMER">토큰 채널 고객</option> <option value="CHANNEL_CUSTOMER">토큰 채널 게임 사용자</option>
</optgroup> </optgroup>
<optgroup label="KB · 토큰 식별"> <optgroup label="게임 데이터 · 토큰 식별">
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option> <option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
</optgroup> </optgroup>
<optgroup label="SQL · 정적 조건"> <optgroup label="SQL · 정적 조건">
@@ -179,7 +179,7 @@
</div> </div>
<details class="explanation-details"> <details class="explanation-details">
<summary>행 규칙의 두 가지 적용 방식 보기</summary> <summary>행 규칙의 두 가지 적용 방식 보기</summary>
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong> <code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.</p> <p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context와 DB에 등록된 업무 관계를 바탕으로 행 접근 함수가 해석합니다. 관계가 없는 게임 데이터 객체에는 조건 코드를 억지로 적용하지 말고, <strong>정적 SQL 조건식</strong>으로 <code>GAME_ID = 'CZN'</code>처럼 실제 컬럼을 사용하세요. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.</p>
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p> <p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
</details> </details>
<details class="explanation-details"> <details class="explanation-details">
@@ -196,28 +196,28 @@
</tr> </tr>
<tr> <tr>
<td><code>TOKEN_SUBJECT</code></td> <td><code>TOKEN_SUBJECT</code></td>
<td>토큰으로 식별된 이해관계자 본인 행</td> <td>토큰으로 식별된 운영 사용자 범위</td>
<td><code>USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td> <td>대상 객체가 토큰 주체 식별 컬럼을 가질 때만 DB 행 접근 함수가 생성</td>
</tr> </tr>
<tr> <tr>
<td><code>OWN_CONTRACT</code></td> <td><code>OWN_CONTRACT</code></td>
<td>토큰 사용자가 담당 설계사인 계약</td> <td>토큰 사용자가 담당하는 게임 서비스 범위</td>
<td><code>FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td> <td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
</tr> </tr>
<tr> <tr>
<td><code>CHANNEL_CONTRACT</code></td> <td><code>CHANNEL_CONTRACT</code></td>
<td>토큰 사용자의 채널에 속한 계약</td> <td>토큰 채널에 속한 게임 서비스 범위</td>
<td><code>FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')</code></td> <td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
</tr> </tr>
<tr> <tr>
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td> <td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
<td>계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행</td> <td>게임 서비스·사용자 기준으로 연결되는 게임 로그·판매·환불 데이터 범위</td>
<td><code>EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)</code></td> <td>대상 테이블의 실제 키 관계를 DB 행 접근 함수가 검증한 뒤 생성</td>
</tr> </tr>
<tr> <tr>
<td><code>STATIC_SQL</code></td> <td><code>STATIC_SQL</code></td>
<td>현재 객체 컬럼으로 표현한 고정 조건</td> <td>현재 객체 컬럼으로 표현한 고정 조건</td>
<td><code>CONTRACT_STATUS = '정상'</code>처럼 검증된 현재 객체 컬럼 조건</td> <td><code>GAME_ID = 'CZN'</code>처럼 검증된 현재 객체 컬럼 조건</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -29,9 +29,9 @@
<tbody> <tbody>
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'"> <tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td> <td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
<td><code th:text="${item.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code></td> <td><code th:text="${item.fileName()}">71_sg_identity_administration.sql</code></td>
<td> <td>
<strong th:text="${item.title()}">컬럼 마스킹 규칙 메타데이터</strong> <strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
<div class="form-hint" th:text="${item.description()}">설명</div> <div class="form-hint" th:text="${item.description()}">설명</div>
</td> </td>
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td> <td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td>
@@ -45,13 +45,13 @@
<section class="content-band" th:if="${selectedScript}"> <section class="content-band" th:if="${selectedScript}">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">ASO / 마스킹</span> <span class="badge text-bg-secondary" th:text="${selectedScript.category()}">Smilegate 권한</span>
<h2 class="mt-2" th:text="${selectedScript.title()}">컬럼 마스킹 규칙 메타데이터</h2> <h2 class="mt-2" th:text="${selectedScript.title()}">사용자·그룹·역할 관리 모델</h2>
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p> <p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
</div> </div>
<code th:text="${selectedScript.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code> <code th:text="${selectedScript.fileName()}">71_sg_identity_administration.sql</code>
</div> </div>
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/62_kb_aso_masking_backoffice_metadata.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p> <p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/71_sg_identity_administration.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3"> <form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}"> <input type="hidden" name="script" th:value="${selectedScript.scriptId()}">

View File

@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('HMM 액세스 토큰')}"></head> <head th:replace="~{fragments/layout :: head('Smilegate 액세스 토큰')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>HMM 액세스 토큰</h1> <h1>Smilegate 액세스 토큰</h1>
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <summary>도움말</summary>
<p>HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p> <p>Data &amp; AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
</details> </details>
</div> </div>
@@ -27,14 +27,14 @@
<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">활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p> <p class="section-subtitle">활성 Data &amp; AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
</div> </div>
</div> </div>
<form method="post" action="/tokens" class="form-grid"> <form method="post" action="/tokens" 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>
HMM 직원 도구 사용자
<select class="form-select" name="userId" required> <select class="form-select" name="userId" required>
<option value="" selected disabled>토큰을 발급할 직원 선택</option> <option value="" selected disabled>토큰을 발급할 직원 선택</option>
<option th:each="user : ${users}" <option th:each="user : ${users}"
@@ -48,7 +48,7 @@
</label> </label>
<label> <label>
용도 메모 용도 메모
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인"> <input class="form-control" name="description" maxlength="200" placeholder="예: 게임 데이터 AI 질의 검증">
</label> </label>
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button> <button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
</form> </form>

View File

@@ -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() + ')'}">sg-teamlead (SG-001)</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()}">SGMP_POC.CZN_COMN_USER_MST.GUID</option>
</select> </select>
</label> </label>
<div> <div>
@@ -85,9 +85,9 @@
<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()}">sg-teamlead</td>
<td><code th:text="${userRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td> <td><code th:text="${userRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</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>
<td> <td>

View File

@@ -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()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
<td><code th:text="${policy.policyName()}">KB_KB_CONTRACTS_ROW_POLICY</code></td> <td><code th:text="${policy.policyName()}">SG_CZN_USER_ROW_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>
@@ -105,7 +105,7 @@
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr> <tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr>
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr> <tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr> <tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr> <tr><td>컬럼 보호</td><td>행 필터(VPD)와 게임 사용자 식별자·거래 식별자의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -23,8 +23,8 @@ class MaskingPolicySynchronizerTest {
} }
@Test @Test
void kbContractsPremiumPolicyIsManagedByBackoffice() { void smilegateSalesTransactionPolicyIsManagedByBackoffice() {
assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS")) assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN"))
.isEqualTo("KB_CONTRACT_PREMIUM_REDACT"); .isEqualTo("SG_SALES_TXN_REDACT");
} }
} }

View File

@@ -9,9 +9,9 @@ import org.junit.jupiter.api.Test;
class McpSseServiceTest { class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService(); private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService();
private final McpSseService service = new McpSseService( private final McpSseService service = new McpSseService(
selectAiAgentOrdsService, selectAiService,
objectMapper objectMapper
); );
@@ -22,12 +22,12 @@ class McpSseServiceTest {
var tools = response.path("result").path("tools"); var tools = response.path("result").path("tools");
assertThat(tools).hasSize(1); assertThat(tools).hasSize(1);
var selectAi = tools.get(0); var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd"); assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql");
assertThat(selectAi.path("inputSchema").path("required")) assertThat(selectAi.path("inputSchema").path("required"))
.extracting(node -> node.asText()) .extracting(node -> node.asText())
.contains("prompt"); .contains("prompt");
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue(); assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
} }
@@ -35,31 +35,29 @@ class McpSseServiceTest {
void callsVpdSelectAiThroughOrdsService() { void callsVpdSelectAiThroughOrdsService() {
ObjectNode request = request(2, "tools/call"); ObjectNode request = request(2, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params"); ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd"); params.put("name", "oracle.select_ai.smilegate_game_text2sql");
ObjectNode arguments = params.putObject("arguments"); ObjectNode arguments = params.putObject("arguments");
arguments.put("prompt", "고객 수를 조회해 줘"); arguments.put("prompt", "카제나 AU를 조회해 줘");
arguments.put("limit", 25);
ObjectNode response = service.handle("default", request, "user-bearer"); ObjectNode response = service.handle("default", request, "user-bearer");
CapturingSelectAiAgentOrdsService agentService = CapturingSmilegateSelectAiService agentService =
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService; (CapturingSmilegateSelectAiService) selectAiService;
assertThat(agentService.bearerToken).isEqualTo("user-bearer"); assertThat(agentService.bearerToken).isEqualTo("user-bearer");
assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘"); assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
assertThat(agentService.limit).isEqualTo(25);
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()) assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") .contains("SGMP_POC_HAIKU45")
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS"); .contains("SELECT 1 FROM DUAL");
} }
@Test @Test
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
ObjectNode request = request(3, "tools/call"); ObjectNode request = request(3, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params"); ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd"); params.put("name", "oracle.select_ai.smilegate_game_text2sql");
params.putObject("arguments").put("prompt", "고객 수를 조회해 줘"); params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘");
ObjectNode response = service.handle("default", request, ""); ObjectNode response = service.handle("default", request, "");
@@ -78,24 +76,22 @@ class McpSseServiceTest {
return request; return request;
} }
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService { private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService {
private String bearerToken; private String bearerToken;
private String prompt; private String prompt;
private int limit;
private CapturingSelectAiAgentOrdsService() { private CapturingSmilegateSelectAiService() {
super(null, null, new ObjectMapper()); super(null, null, null, new ObjectMapper());
} }
@Override @Override
public JsonNode run(String bearerToken, String prompt, int limit) { public JsonNode generateShowSql(String bearerToken, String prompt) {
this.bearerToken = bearerToken; this.bearerToken = bearerToken;
this.prompt = prompt; this.prompt = prompt;
this.limit = limit;
return new ObjectMapper().createObjectNode() return new ObjectMapper().createObjectNode()
.put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") .put("profile", "SGMP_POC_HAIKU45")
.put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS"); .put("generatedSql", "SELECT 1 FROM DUAL");
} }
} }
} }

View File

@@ -14,16 +14,13 @@ class SecuritySqlScriptServiceTest {
assertThat(service.list()) assertThat(service.list())
.extracting(item -> item.fileName()) .extracting(item -> item.fileName())
.containsExactly( .containsExactly(
"62_kb_aso_masking_backoffice_metadata.sql", "70_sg_tool_user.sql",
"63_kb_aso_masking_rule_runtime.sql", "71_sg_identity_administration.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("smilegate-identity-administration").source())
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query") .contains("create table sg_app_user")
.contains("Authorization: Bearer <VPD token>"); .contains("DATA_AI_TF");
} }
@Test @Test

View File

@@ -12,22 +12,22 @@ class StructuredDataServiceTest {
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class));
@Test @Test
void exposesOnlyTheSevenApprovedStructuredTables() { void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
assertThat(service.tables()) assertThat(service.tables())
.extracting(table -> table.tableName()) .extracting(table -> table.tableName())
.containsExactly( .containsExactly(
"KB_CUSTOMERS", "CZN_COMN_USER_MST",
"KB_PRODUCTS", "CZN_COMN_CHARACTER_MST",
"KB_CONTRACTS", "COMN_SALES_TXN",
"KB_COVERAGES", "COMN_REFUND_TXN",
"KB_CLAIMS", "COMN_SALES_PRODUCT_DISP_BAS",
"KB_EXTERNAL_HOLDINGS", "COMN_GAME_SERVER_BAS",
"KB_STAKEHOLDERS"); "COMN_GAME_ALIAS_BAS");
} }
@Test @Test
void rejectsAnyTableOutsideTheServerSideAllowlist() { void rejectsAnyTableOutsideTheServerSideAllowlist() {
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG")) assertThatThrownBy(() -> service.requireTable("security-audit-log"))
.isInstanceOf(AppException.class) .isInstanceOf(AppException.class)
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); .hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
} }

View File

@@ -35,7 +35,7 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow") .doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<"); .doesNotContain(">01<", ">02<");
assertThat(layout) assertThat(layout)
.contains("데이터 접근 제어") .contains("운영 사용자 관리")
.contains("접근 제어") .contains("접근 제어")
.contains("보호·검증") .contains("보호·검증")
.contains("연동 도구") .contains("연동 도구")
@@ -122,7 +122,7 @@ class GuidedFlowTemplateTest {
.contains("data-wizard-target=\"2\">대상") .contains("data-wizard-target=\"2\">대상")
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number"); .doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
assertThat(tokens) assertThat(tokens)
.contains("직원 토큰 발급", "검증 세션 발급") .contains("도구 사용자 토큰 발급", "검증 세션 발급")
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급"); .doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
assertThat(probe) assertThat(probe)
.contains("접근 검증 실행", "<strong>검증 결과</strong>") .contains("접근 검증 실행", "<strong>검증 결과</strong>")
@@ -198,7 +198,7 @@ class GuidedFlowTemplateTest {
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js")); String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html) assertThat(html)
.contains("value=\"OWN_CUSTOMER\">본인 담당 고객") .contains("value=\"OWN_CUSTOMER\">담당 게임 사용자")
.contains("value=\"STATIC_SQL\">정적 SQL 조건식") .contains("value=\"STATIC_SQL\">정적 SQL 조건식")
.contains("행 규칙의 두 가지 적용 방식 보기") .contains("행 규칙의 두 가지 적용 방식 보기")
.contains("한 권한 안의 규칙은 모두 AND") .contains("한 권한 안의 규칙은 모두 AND")
@@ -221,8 +221,8 @@ class GuidedFlowTemplateTest {
String filters = template("vpd-filter-policies.html"); String filters = template("vpd-filter-policies.html");
assertThat(roles) assertThat(roles)
.contains("표시 보호 등급 상한") .contains("스마일게이트 Data & AI PoC 운영 역할")
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다"); .contains("데이터 취급 등급");
assertThat(tokens) assertThat(tokens)
.contains("회수·만료 포함") .contains("회수·만료 포함")
.contains("현재 사용 가능한 토큰만"); .contains("현재 사용 가능한 토큰만");

View File

@@ -31,26 +31,26 @@ class MaskingRuleTemplateRenderTest {
var engine = new SpringTemplateEngine(); var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver); engine.setTemplateResolver(resolver);
var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y"); var object = new ProtectedObject(7L, "SGMP_POC", "CZN_COMN_USER_MST", "sgmp-poc/czn-users", "Y");
var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y"); var transactionObject = new ProtectedObject(8L, "SGMP_POC", "COMN_SALES_TXN", "sgmp-poc/sales", "Y");
var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY"); var column = new ProtectedColumn(11L, 7L, "GUID", "Y", null, "RESTRICTED", "NULLIFY");
var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y"); var rule = new MaskingRule(1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "테스트", "Y");
var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED", var columnRule = new ColumnMaskingRule(11L, 7L, "SGMP_POC", "CZN_COMN_USER_MST", "GUID",
1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y"); 1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "Y");
var context = new Context(Locale.KOREAN); var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("templates", List.of(MaskingTemplate.values())); context.setVariable("templates", List.of(MaskingTemplate.values()));
context.setVariable("rules", List.of(rule)); context.setVariable("rules", List.of(rule));
context.setVariable("columnRules", List.of(columnRule)); context.setVariable("columnRules", List.of(columnRule));
var status = new MaskingPolicyStatus( var status = new MaskingPolicyStatus(
"POC_2", "KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT", "YES", 1, 1, 0, 0 "SGMP_POC", "CZN_COMN_USER_MST", "SG_CZN_USER_REDACT", "YES", 1, 1, 0, 0
); );
context.setVariable("policyStatuses", List.of(status)); context.setVariable("policyStatuses", List.of(status));
context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status)); context.setVariable("policyStatusByObjectName", Map.of("CZN_COMN_USER_MST", status));
context.setVariable("objects", List.of(object, contractObject)); context.setVariable("objects", List.of(object, transactionObject));
context.setVariable("maskingTargetObjects", List.of(object, contractObject)); context.setVariable("maskingTargetObjects", List.of(object, transactionObject));
context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of())); context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of()));
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("CUST_NM"), 8L, List.of("PREMIUM"))); context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("GUID"), 8L, List.of("GUID")));
String rulesPage = engine.process("masking-rules", context); String rulesPage = engine.process("masking-rules", context);
@@ -64,13 +64,13 @@ class MaskingRuleTemplateRenderTest {
.contains("DB Redaction 컬럼") .contains("DB Redaction 컬럼")
.contains("레거시 VPD 컬럼 제어") .contains("레거시 VPD 컬럼 제어")
.contains("마스킹 대상 컬럼 추가") .contains("마스킹 대상 컬럼 추가")
.contains("POC_2.KB_CONTRACTS.PREMIUM") .contains("SGMP_POC.COMN_SALES_TXN.GUID")
.contains("KB_CUSTOMER_PII_REDACT") .contains("SG_CZN_USER_REDACT")
.contains("적용됨"); .contains("적용됨");
context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y"))); context.setVariable("users", List.of(new AppUser(2L, "sg-teamlead", "SG-001", "DATA_AI", "N", "Y")));
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L, context.setVariable("userRules", List.of(new UserMaskingRule(2L, "sg-teamlead", 11L,
"POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y"))); "SGMP_POC", "CZN_COMN_USER_MST", "GUID", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "UNMASK", "Y")));
String userPage = engine.process("user-masking-rules", context); String userPage = engine.process("user-masking-rules", context);
assertThat(userPage) assertThat(userPage)