diff --git a/.env.example b/.env.example index 11a65a2..9c140a6 100644 --- a/.env.example +++ b/.env.example @@ -52,42 +52,14 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_PASSWORD="" -# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다. +# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다. +# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다. export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" -export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}" -export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}" -export BACKOFFICE_SELECT_AI_PROFILE="" -# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다. -# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요. -export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}" -export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS" -export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD="" -# Optional deployment-specific JSON contract. Keep project rules out of Java. -export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE="" +export BACKOFFICE_SELECT_AI_DB_USERNAME="SGMP_POC" +export BACKOFFICE_SELECT_AI_DB_PASSWORD="" +export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_HAIKU45" -# --- (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 도구 호환 설정. 여러 Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다. -# AGENT_TOOL targetName은 서버 시작 시 USER_AI_AGENT_TOOLS의 ENABLED 상태를 검증합니다. -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 탭) --- +# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- export BACKOFFICE_AI_ENABLED="false" export BACKOFFICE_AI_PROVIDER="openai" # openai | oci export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com diff --git a/.gitignore b/.gitignore index 357e835..3640865 100644 --- a/.gitignore +++ b/.gitignore @@ -17,11 +17,9 @@ logs/ # Java / Maven target/ -# Python +# Python / Streamlit __pycache__/ *.py[cod] -.venv/ -data/ # Locally downloaded development tools (for example SQLcl) .tools/ diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py index 26d6e08..ad3577e 100644 --- a/ai-web-agent-console/app.py +++ b/ai-web-agent-console/app.py @@ -73,7 +73,7 @@ from ai_web_agent_console.query_contracts import ( LOG = logging.getLogger(__name__) MCP_PROTOCOL_VERSION = "2025-11-25" -PREFERRED_TOOL = "search_hr_data" +PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql" DEFAULT_QUESTION = "" MAX_RESPONSE_BYTES = 1_000_000 MAX_CONVERSATION_MESSAGES = 8 @@ -91,15 +91,21 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci" ENV_FILE = ROOT / ".env" MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.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" CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" -DEFAULT_VPD_USER_ID = "E1001" -VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/" +DEFAULT_VPD_USER_ID = "sg-teamlead" +VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" -PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User" -PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires" +PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" +PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" +PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 +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( os.environ.get("AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE") or os.environ.get("POC4_AUDIT_DB_ENV_FILE") @@ -918,6 +924,8 @@ def collect_security_evidence( ) -> Mapping[str, Any]: """Collect predefined, token-scoped security evidence without exposing secrets.""" + if not REFERENCE_EVIDENCE_ENABLED: + return {} kind = _security_evidence_kind(question) if not kind or token_preset is None: return {} @@ -1482,6 +1490,8 @@ def collect_business_evidence( ) -> Mapping[str, Any]: """Collect token-validated, explicitly scoped business and catalog evidence.""" + if not REFERENCE_EVIDENCE_ENABLED: + return {} kind = _business_evidence_kind(question) if not kind or token_preset is None: return {} @@ -5578,13 +5588,10 @@ def main() -> None: ) else: selected_token_preset = None - selected_preset_bearer = ( - selected_token_preset.token if selected_token_preset is not None else "" - ) - if configured_mcp_bearer or selected_preset_bearer: - # The HMM MCP gateway credential is never rendered. Each demo-user - # preset refers to its runtime env key, allowing secure profile swaps. - st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용") + if configured_mcp_bearer: + # The Smilegate MCP gateway uses a server-side credential. UI-only + # context must never be forwarded as a second bearer token. + st.caption("MCP 인증: 서버 관리 토큰 적용") manual_bearer_token = "" elif selected_token_preset is not None: manual_bearer_token = st.text_input( @@ -5765,9 +5772,7 @@ def main() -> None: '
', unsafe_allow_html=True, ) - architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs( - ["아키텍처", "시나리오", "감사로그", "보안관리"] - ) + architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"]) with architecture_tab: _render_architecture_tab() @@ -5868,17 +5873,6 @@ def main() -> None: height=0, ) - with audit_tab: - render_hmm_audit_tab( - st, - _load_hmm_audit_inventory, - _load_hmm_audit_events, - AuditLogError, - ) - - with operations_tab: - _render_vpd_operations_tab() - if not submitted: return diff --git a/ai-web-agent-console/config/app_profile.json b/ai-web-agent-console/config/app_profile.json index eea68d3..304c152 100644 --- a/ai-web-agent-console/config/app_profile.json +++ b/ai-web-agent-console/config/app_profile.json @@ -1,21 +1,21 @@ { "version": 1, "product": { - "name": "AI 업무 에이전트", - "short_name": "AGENT", - "page_title": "AI 업무 에이전트", + "name": "SMILEGATE DATA & AI POC", + "short_name": "SMILEGATE", + "page_title": "SMILEGATE DATA & AI POC", "page_icon": "🤖", - "header_title": "AI 업무 에이전트", - "header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.", - "login_kicker": "DATA & AI DEMO", - "login_title": "AI 업무 에이전트", - "login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.", - "login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다." + "header_title": "스마일게이트 게임 데이터 AI 에이전트", + "header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.", + "login_kicker": "SMILEGATE DATA & AI POC", + "login_title": "스마일게이트 게임 데이터 AI 에이전트", + "login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.", + "login_footer": "승인된 Data & AI PoC 사용자만 접근할 수 있습니다." }, "theme": { - "primary_color": "#003b70", - "text_color": "#172b3a", - "muted_color": "#667785", - "border_color": "#dfe7ed" + "primary_color": "#113F67", + "text_color": "#15283B", + "muted_color": "#5D6C7C", + "border_color": "#D7E0E8" } } diff --git a/ai-web-agent-console/config/mcp_servers.json b/ai-web-agent-console/config/mcp_servers.json index 3da95f4..a5fd504 100644 --- a/ai-web-agent-console/config/mcp_servers.json +++ b/ai-web-agent-console/config/mcp_servers.json @@ -1,23 +1,20 @@ { - "default_server_id": "hmm_hr_mcp", + "default_server_id": "smilegate_game_data_mcp", "servers": [ { - "id": "hmm_hr_mcp", + "id": "smilegate_game_data_mcp", "enabled": true, - "provider": "hmm_compat_mcp", + "provider": "smilegate_select_ai_mcp", "transport": "http", - "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", - "auth_token_env": "HMM_MCP_BEARER_TOKEN", - "timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS", - "default_tool": "search_hr_data", + "endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp", + "auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN", + "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", + "default_tool": "oracle.select_ai.smilegate_game_text2sql", "router_model_profile": "gpt54_mini_oci", "tool_allowlist": [ - "search_hr_data", - "resolve_hr_term", - "search_hr_policy", - "search_carrier_performance" + "oracle.select_ai.smilegate_game_text2sql" ], - "description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server" + "description": "Smilegate game-data Text2SQL MCP server" } ] } diff --git a/ai-web-agent-console/config/smilegate_demo_scenarios.json b/ai-web-agent-console/config/smilegate_demo_scenarios.json new file mode 100644 index 0000000..46ca34a --- /dev/null +++ b/ai-web-agent-console/config/smilegate_demo_scenarios.json @@ -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": "최신 월 기준 게임별 신규 사용자 수를 보여줘" + } + ] +} diff --git a/ai-web-agent-console/config/vpd_token_presets.json b/ai-web-agent-console/config/vpd_token_presets.json index ecd510d..dae699c 100644 --- a/ai-web-agent-console/config/vpd_token_presets.json +++ b/ai-web-agent-console/config/vpd_token_presets.json @@ -1,52 +1,14 @@ { - "version": 2, - "description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.", "presets": [ { - "enabled": true, + "enabled": false, "default": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", - "user_id": "E1001", - "name": "Kim Minseo", - "role": "HR Team Manager", - "team": "HMM HR Demo Team", - "scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모" - }, - { - "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", - "user_id": "E1002", - "name": "Lee Jiwon", - "role": "HR Operations Specialist", - "team": "HMM HR Demo Team", - "scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모" - }, - { - "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", - "user_id": "E1003", - "name": "Park Dohyun", - "role": "People Analytics Analyst", - "team": "HMM HR Demo Team", - "scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모" - }, - { - "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", - "user_id": "E1005", - "name": "Han Seojun", - "role": "Recruiting Specialist", - "team": "HMM HR Demo Team", - "scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모" - }, - { - "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", - "user_id": "E1007", - "name": "Kang Minho", - "role": "HR Coordinator", - "team": "HMM HR Demo Team", - "scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모" + "token": "vpd_live_REPLACE_WITH_USER_TOKEN", + "user_id": "FC00789", + "name": "김설계", + "role": "설계사", + "channel": "설계사", + "scope": "본인 담당 계약 고객" } ] } diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py index 7c0b3e6..8a1a034 100644 --- a/ai-web-agent-console/tests/test_scenarios.py +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -6,134 +6,44 @@ import tempfile import unittest from unittest.mock import patch -from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios -from ai_web_agent_console.profile import load_app_profile -from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments -from ai_web_agent_console.mcp_result import ( - has_actionable_text_result, - status_result_evidence, - status_result_summary, -) -from ai_web_agent_console.model_registry import load_model_registry - - -def _load_console_query_helpers(): - """Load the Streamlit entrypoint only when its optional runtime is installed.""" - - try: - from app import _prepare_hmm_hr_tool_query - except ModuleNotFoundError: - return None - return _prepare_hmm_hr_tool_query +from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios +from src.agent_console.profile import load_app_profile class DemoScenarioConfigTest(unittest.TestCase): - def test_model_registry_uses_console_names(self) -> None: - registry = load_model_registry() - - self.assertEqual( - registry.registry_name, - "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES", - ) - self.assertTrue(registry.default_profile.default_for_console) - def test_profile_environment_overrides_json_defaults(self) -> None: path = Path(__file__).parents[1] / "config" / "app_profile.json" with patch.dict( "os.environ", { - "AGENT_CONSOLE_SHORT_NAME": "HMM", - "AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트", - "AGENT_CONSOLE_PRIMARY_COLOR": "#003b70", + "AGENT_CONSOLE_SHORT_NAME": "SMILEGATE", + "AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC", + "AGENT_CONSOLE_PRIMARY_COLOR": "#113F67", }, clear=False, ): profile = load_app_profile(path) - self.assertEqual(profile.short_name, "HMM") - self.assertEqual(profile.page_title, "HMM AI 업무 에이전트") - self.assertEqual(profile.primary_color, "#003b70") + self.assertEqual(profile.short_name, "SMILEGATE") + self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC") + self.assertEqual(profile.primary_color, "#113F67") def test_profile_reads_dotenv_values(self) -> None: path = Path(__file__).parents[1] / "config" / "app_profile.json" with tempfile.TemporaryDirectory() as temp_dir: 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) - self.assertEqual(profile.short_name, "HMM") + self.assertEqual(profile.short_name, "SMILEGATE") - def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None: - path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py" - source = path.read_text(encoding="utf-8") - - self.assertIn('[data-testid="stAppViewContainer"] li', source) - self.assertIn('[data-testid="stExpander"] summary', source) - self.assertIn('div[data-testid="stButton"] > button', source) - self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source) - self.assertIn('[data-testid="stTab"]', source) - self.assertIn('[role="tab"][aria-selected="true"]', source) - - def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None: - root = Path(__file__).parents[1] - entrypoint = (root / "app.py").read_text( - encoding="utf-8" - ) - renderer = (root / "ai_web_agent_console" / "audit.py").read_text( - encoding="utf-8" - ) - - self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint) - self.assertIn("_load_hmm_audit_inventory", entrypoint) - self.assertIn("(protocol=tcps)(port=1521)", entrypoint) - self.assertIn( - "AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD", - entrypoint, - ) - self.assertIn("HMM 접근 관리", renderer) - self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint) - - def test_hmm_scenarios_are_enabled_and_unique(self) -> None: - path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" + def test_smilegate_scenarios_are_enabled_and_unique(self) -> None: + path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json" scenarios = load_demo_scenarios(path) self.assertGreaterEqual(len(scenarios), 3) self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios})) self.assertTrue(all(item.question.strip() for item in scenarios)) - by_id = {item.scenario_id: item for item in scenarios} - self.assertEqual( - {"FED-01", "FED-02", "FED-03"}, - {"FED-01", "FED-02", "FED-03"} & set(by_id), - ) - self.assertTrue( - all("선사" in by_id[scenario_id].question for scenario_id in ( - "FED-01", "FED-02", "FED-03" - )) - ) - - def test_hmm_mcp_allows_carrier_federation_tool(self) -> None: - path = Path(__file__).parents[1] / "config" / "mcp_servers.json" - payload = json.loads(path.read_text(encoding="utf-8")) - server = next( - item for item in payload["servers"] if item["id"] == "hmm_hr_mcp" - ) - - self.assertIn( - "search_carrier_performance", - server["tool_allowlist"], - ) - - def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None: - path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json" - payload = json.loads(path.read_text(encoding="utf-8")) - presets = payload["presets"] - - self.assertEqual(payload["version"], 2) - self.assertEqual({item["user_id"] for item in presets}, { - "E1001", "E1002", "E1003", "E1005", "E1007" - }) - self.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" for item in presets)) - self.assertTrue(all("token" not in item for item in presets)) def test_duplicate_id_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -152,87 +62,6 @@ class DemoScenarioConfigTest(unittest.TestCase): with self.assertRaises(ScenarioConfigError): load_demo_scenarios(path) - def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None: - tool = McpTool( - name="search_hr_data", - description="", - schema={ - "type": "object", - "properties": {"query": {"type": "string"}}, - "required": ["query"], - }, - read_only=True, - ) - - arguments = build_mcp_tool_arguments( - tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data" - ) - - self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"}) - - def test_term_tool_arguments_follow_discovered_term_schema(self) -> None: - tool = McpTool( - name="resolve_hr_term", - description="", - schema={ - "type": "object", - "properties": {"term": {"type": "string"}}, - "required": ["term"], - }, - read_only=True, - ) - - arguments = build_mcp_tool_arguments( - tool, "반차", 50, preferred_tool="search_hr_data" - ) - - self.assertEqual(arguments, {"term": "반차"}) - - def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None: - result = { - "status": "success", - "result": ( - "HR_POLICY_SEARCH_RESULT\n" - "EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준" - ), - } - - summary = status_result_summary(result, excerpt_chars=40) - evidence = status_result_evidence(result) - - self.assertEqual(summary["status"], "success") - self.assertGreater(summary["result_chars"], 40) - self.assertIn("KR_Leave_Policy.pdf", evidence["result"]) - self.assertTrue(has_actionable_text_result(result)) - - def test_no_data_text_is_not_actionable(self) -> None: - self.assertFalse( - has_actionable_text_result({"status": "success", "result": "No data found"}) - ) - - @unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional") - def test_policy_query_does_not_include_demo_user_context(self) -> None: - prepare = _load_console_query_helpers() - assert prepare is not None - tool = McpTool( - name="search_hr_policy", - description="Search policy documents", - schema={"properties": {"query": {"type": "string"}}}, - read_only=True, - ) - - query = prepare( - question="연차 휴가 이월 기준과 제한을 알려줘", - tool=tool, - model_profile_key="gpt54_mini_oci", - selected_user_id="E1001", - selected_user_role="HR Team Manager", - selected_user_team="HMM HR Demo Team", - selected_user_scope="팀원 6명 관리", - ) - - self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘") - if __name__ == "__main__": unittest.main() diff --git a/docs/design/703-smilegate-backoffice-finalization/README.md b/docs/design/703-smilegate-backoffice-finalization/README.md new file mode 100644 index 0000000..d0f5be2 --- /dev/null +++ b/docs/design/703-smilegate-backoffice-finalization/README.md @@ -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 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다. diff --git a/docs/design/704-smilegate-branch-isolation/README.md b/docs/design/704-smilegate-branch-isolation/README.md new file mode 100644 index 0000000..2d1a57b --- /dev/null +++ b/docs/design/704-smilegate-branch-isolation/README.md @@ -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 전용 작업 경로에서만 수행한다. +- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다. diff --git a/vpd-backoffice/pom.xml b/vpd-backoffice/pom.xml index d49518b..1c3329c 100644 --- a/vpd-backoffice/pom.xml +++ b/vpd-backoffice/pom.xml @@ -98,7 +98,8 @@ ../database/adb database/adb - **/*.sql + 70_sg_tool_user.sql + 71_sg_identity_administration.sql diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 3b31783..804fcec 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -74,16 +74,12 @@ public record BackofficeProperties( } } - /** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */ + /** Separate ADB connection because Select AI profiles are owned by SGMP_POC. */ public record SelectAi( String dbUrl, String dbUsername, String dbPassword, - String profile, - String runtimeDbUrl, - String runtimeDbUsername, - String runtimeDbPassword, - String queryContractFile + String profile ) { public boolean configured() { @@ -92,12 +88,5 @@ public record BackofficeProperties( && dbPassword != null && !dbPassword.isBlank() && profile != null && !profile.isBlank(); } - - /** The generated SQL must never fall back to the privileged profile-owner connection. */ - public boolean runtimeConfigured() { - return runtimeDbUrl != null && !runtimeDbUrl.isBlank() - && runtimeDbUsername != null && !runtimeDbUsername.isBlank() - && runtimeDbPassword != null && !runtimeDbPassword.isBlank(); - } } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java index 1ea66b1..85e1faa 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java @@ -51,6 +51,6 @@ public class DbPoolWarmup { groupService.findGroupRoles(); permissionService.findRoles(); 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); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java index a46e5d1..5c74db0 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java @@ -27,10 +27,10 @@ public enum MaskingTemplate { "A******** (예시)"), RRN_PARTIAL( "RRN_PARTIAL", - "주민등록번호 부분 마스킹", - "앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.", + "식별번호 부분 마스킹", + "앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.", "DBMS_REDACT.REGEXP", - "900101-******* (예시)"); + "123456-******* (예시)"); private final String code; private final String label; diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java index 2409b08..9cf213d 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java @@ -50,16 +50,16 @@ public record PermissionView( return "토큰으로 식별된 이해관계자 본인 행"; } if (upper.contains(" OWN_CONTRACT")) { - return "담당 설계사 본인 계약"; + return "담당 게임 서비스 범위"; } if (upper.contains(" CHANNEL_CONTRACT")) { - return "토큰 사용자의 채널 계약"; + return "토큰 채널 게임 서비스 범위"; } if (upper.contains(" OWN_CUSTOMER")) { - return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유"; + return "담당 게임 사용자·거래 데이터 범위"; } if (upper.contains(" CHANNEL_CUSTOMER")) { - return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유"; + return "토큰 채널 게임 사용자·거래 데이터 범위"; } if (upper.contains(" STATIC_SQL ")) { return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", ""); diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 173901d..5f88dfc 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -258,8 +258,8 @@ public class BackofficeSchemaService { "문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"), new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL", "첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"), - new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL", - "앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식") + new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL", + "앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식") ); private static final String MASKING_RULE_SEED_SQL = """ @@ -707,16 +707,21 @@ public class BackofficeSchemaService { private String sqlclScript(String currentUser) { String owner = currentUser == null || currentUser.isBlank() ? "ADMIN" : currentUser; return """ - -- sqlcl에서 실행할 HMM VPD runtime 준비 순서 - -- 1. ADMIN으로 접속 - @database/adb/25_agent_ords_security_backoffice_support.sql - @database/adb/72_hmm_leave_team_vpd.sql + -- sqlcl에서 실행할 VPD/ORDS runtime 준비 순서 + -- 1. ADMIN 또는 보호 객체 owner로 접속 + @sql/adb/17_agent_ords_security_local_vpd_setup.sql + @sql/adb/25_agent_ords_security_backoffice_support.sql + @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql + @sql/adb/71_sg_identity_administration.sql + @sql/adb/21_agent_ords_security_ords_enable_schema.sql -- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한 CONNECT %s/@ - GRANT EXECUTE ON hmm_access_ctx_pkg TO ; - GRANT SELECT ON hmm_leave_balances TO ; - GRANT SELECT ON hmm_leave_requests TO ; + GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; + GRANT SELECT ON . TO cb_ords; + + -- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결 + -- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다. """.formatted(owner.toLowerCase()); } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index 04c6b28..b40709c 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Service; @Service public class MaskingPolicySynchronizer { + private static final String OWNER = "SGMP_POC"; private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); private final JdbcTemplate jdbcTemplate; @@ -38,8 +39,15 @@ public class MaskingPolicySynchronizer { ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - this.dataCatalog = dataCatalog; - this.policyCatalog = policyCatalog; + } + + private static Map managedPolicyMap() { + Map policies = new LinkedHashMap<>(); + policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT"); + policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT"); + policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT"); + policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT"); + return Collections.unmodifiableMap(policies); } public Set managedObjectNames() { diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index b520f56..70df7d5 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -55,7 +55,7 @@ public class MaskingRuleService { return mapper.findColumnRules(); } - /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */ + /** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */ public List findPolicyStatuses() { List policies = maskingPolicySynchronizer.managedPolicies(); if (policies.isEmpty()) { diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 7c2705b..a563acd 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -1,7 +1,5 @@ package com.cloudhandson.vpdbackoffice.service; -import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; -import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,35 +8,29 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; -/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */ +/** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */ @Service public class McpSseService { - private static final String MCP_CALL_PATH = "/mcp (tools/call)"; + private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql"; + private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)"; + private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45"; + private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView( + SELECT_AI_VPD_QUERY_TOOL, + "SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.", + -1L, + "Smilegate 게임 데이터 Text2SQL", + SELECT_AI_VPD_QUERY_PATH + ); - private final HmmAiAgentToolRunner agentToolRunner; - private final SelectAiService selectAiService; - private final HmmMcpBearerAuthenticator bearerAuthenticator; - private final McpToolCatalog toolCatalog; - private final McpProperties mcpProperties; - private final BackofficeProperties backofficeProperties; + private final SmilegateSelectAiService smilegateSelectAiService; private final ObjectMapper objectMapper; public McpSseService( - HmmAiAgentToolRunner agentToolRunner, - SelectAiService selectAiService, - HmmMcpBearerAuthenticator bearerAuthenticator, - McpToolCatalog toolCatalog, - McpProperties mcpProperties, - BackofficeProperties backofficeProperties, + SmilegateSelectAiService smilegateSelectAiService, ObjectMapper objectMapper ) { - this.agentToolRunner = agentToolRunner; - this.selectAiService = selectAiService; - this.bearerAuthenticator = bearerAuthenticator; - this.toolCatalog = toolCatalog; - this.mcpProperties = mcpProperties; - this.backofficeProperties = backofficeProperties; + this.smilegateSelectAiService = smilegateSelectAiService; this.objectMapper = objectMapper; } @@ -46,57 +38,47 @@ public class McpSseService { return handle(contextPath, request, ""); } - /** Discovery and execution use the same HMM business-user Bearer token boundary. */ - public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) { - bearerAuthenticator.authenticate(bearerToken); + /** + * The HTTP bearer token is the business-user subject token; no separate MCP token is used. + */ + public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) { ObjectNode response = objectMapper.createObjectNode(); response.put("jsonrpc", "2.0"); if (request != null && request.has("id")) { response.set("id", request.get("id")); } - String method = - request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); - JsonNode parameters = - request == null ? objectMapper.createObjectNode() : request.path("params"); + String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); + JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params"); try { response.set("result", switch (method) { case "initialize" -> initializeResult(contextPath); case "notifications/initialized" -> objectMapper.createObjectNode(); case "tools/list" -> toolsListResult(); - case "tools/call" -> toolsCallResult(parameters, bearerToken); + case "tools/call" -> toolsCallResult(parameters, vpdBearerToken); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); }); - } catch (McpUnauthorizedException exception) { - throw exception; - } catch (Exception exception) { + } catch (Exception e) { response.remove("result"); ObjectNode error = objectMapper.createObjectNode(); error.put("code", -32000); - error.put("message", safeMessage(exception)); + error.put("message", e.getMessage()); response.set("error", error); } return response; } + /** The only tool registered by this MCP server. */ public List registeredTools() { - return toolCatalog.tools().stream() - .map(tool -> new McpToolView( - tool.name(), - tool.description(), - -1L, - tool.label(), - tool.agentTool() ? tool.targetName() : MCP_CALL_PATH - )) - .toList(); + return List.of(SELECT_AI_VPD_QUERY_VIEW); } private ObjectNode initializeResult(String contextPath) { ObjectNode result = objectMapper.createObjectNode(); result.put("protocolVersion", "2024-11-05"); ObjectNode serverInfo = objectMapper.createObjectNode(); - serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath); - serverInfo.put("version", "1.1.0"); + serverInfo.put("name", "vpd-ords-backoffice-" + contextPath); + serverInfo.put("version", "0.1.0"); result.set("serverInfo", serverInfo); ObjectNode capabilities = objectMapper.createObjectNode(); capabilities.set("tools", objectMapper.createObjectNode()); @@ -107,60 +89,58 @@ public class McpSseService { private ObjectNode toolsListResult() { ObjectNode result = objectMapper.createObjectNode(); ArrayNode tools = objectMapper.createArrayNode(); - toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool))); + tools.add(selectAiVpdQueryTool()); result.set("tools", tools); return result; } - private ObjectNode toolDefinition(McpToolDefinition tool) { + private ObjectNode selectAiVpdQueryTool() { ObjectNode item = objectMapper.createObjectNode(); - item.put("name", tool.name()); - item.put("description", tool.description()); + item.put("name", SELECT_AI_VPD_QUERY_TOOL); + item.put("description", SELECT_AI_VPD_QUERY_VIEW.description()); + ObjectNode schema = objectMapper.createObjectNode(); schema.put("type", "object"); ObjectNode properties = objectMapper.createObjectNode(); - ObjectNode argument = objectMapper.createObjectNode(); - argument.put("type", "string"); - argument.put("description", tool.argumentDescription()); - argument.put("maxLength", 4000); - properties.set(tool.argumentName(), argument); + + ObjectNode prompt = objectMapper.createObjectNode(); + prompt.put("type", "string"); + prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다."); + prompt.put("maxLength", 4000); + properties.set("prompt", prompt); + schema.set("properties", properties); ArrayNode required = objectMapper.createArrayNode(); - required.add(tool.argumentName()); + required.add("prompt"); schema.set("required", required); schema.put("additionalProperties", false); item.set("inputSchema", schema); return item; } - private ObjectNode toolsCallResult(JsonNode params, String bearerToken) { - McpToolDefinition tool = toolCatalog.require(params.path("name").asText("")); - String argument = params.path("arguments").path(tool.argumentName()).asText("").trim(); - if (argument.isBlank()) { - throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다."); + private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { + String toolName = params.path("name").asText(""); + if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) { + throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); } - JsonNode toolResponse; - if (tool.agentTool()) { - ObjectNode input = objectMapper.createObjectNode(); - input.put(tool.targetParameterName(), argument); - toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken); - } else { - toolResponse = selectAiService.generateAndExecute(bearerToken, argument); + JsonNode arguments = params.path("arguments"); + String token = vpdBearerToken == null ? "" : vpdBearerToken.trim(); + if (token.isBlank()) { + return tokenAccessDeniedResult(); + } + JsonNode response; + try { + response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText("")); + } catch (VpdTokenAccessDeniedException ignored) { + return tokenAccessDeniedResult(); } ObjectNode payload = objectMapper.createObjectNode(); - payload.put("toolName", tool.name()); - 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.put("toolName", SELECT_AI_VPD_QUERY_TOOL); + payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE); + payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); + payload.set("response", response); ObjectNode result = objectMapper.createObjectNode(); ArrayNode content = objectMapper.createArrayNode(); @@ -173,15 +153,26 @@ public class McpSseService { return result; } - private String safeMessage(Exception exception) { - String message = exception.getMessage(); - return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message; + private ObjectNode tokenAccessDeniedResult() { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("status", "VPD_TOKEN_DENIED"); + payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다."); + + ObjectNode result = objectMapper.createObjectNode(); + ArrayNode content = objectMapper.createArrayNode(); + ObjectNode text = objectMapper.createObjectNode(); + text.put("type", "text"); + text.put("text", pretty(payload)); + content.add(text); + result.set("content", content); + result.put("isError", true); + return result; } private String pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); - } catch (Exception exception) { + } catch (Exception e) { return String.valueOf(value); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index 25b569e..a7f4d21 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -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 // a sensitive column missing from every JSON row is also a NULL result // and must be reported as masked to the MCP/UI caller. diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java index 61dabdb..92b3f50 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -1,43 +1,41 @@ package com.cloudhandson.vpdbackoffice.service; -import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.HashSet; -import java.util.Set; -import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; /** * Read-only catalogue of security deployment SQL bundled from the Git-tracked - * database/adb directory. Script ids are an application whitelist: request input + * sql/adb directory. Script ids are an application whitelist: request input * never becomes a filesystem or classpath path. */ @Service public class SecuritySqlScriptService { - private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}"); - private static final Pattern RESOURCE_PATH = Pattern.compile( - "(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql" + private static final List CURATED_SCRIPTS = List.of( + new ScriptDefinition( + "smilegate-tool-users", + "Smilegate 사용자", + "70_sg_tool_user.sql", + "PoC 도구 사용자 초기 데이터", + "Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다." + ), + new ScriptDefinition( + "smilegate-identity-administration", + "Smilegate 권한", + "71_sg_identity_administration.sql", + "사용자·그룹·역할 관리 모델", + "Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다." + ) ); - private final List scripts; - - public SecuritySqlScriptService( - SecuritySqlScriptProperties properties, - ObjectMapper objectMapper - ) { - scripts = parse(properties.scripts(), objectMapper); - } public List list() { - return scripts.stream() + return CURATED_SCRIPTS.stream() .map(definition -> new SecuritySqlScriptSummary( definition.scriptId(), definition.category(), @@ -49,7 +47,7 @@ public class SecuritySqlScriptService { } public SecuritySqlScript find(String scriptId) { - ScriptDefinition definition = scripts.stream() + ScriptDefinition definition = CURATED_SCRIPTS.stream() .filter(candidate -> candidate.scriptId().equals(scriptId)) .findFirst() .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); @@ -64,7 +62,7 @@ public class SecuritySqlScriptService { } private String readSource(String fileName) { - ClassPathResource resource = new ClassPathResource("database/adb/" + fileName); + ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName); try (InputStream input = resource.getInputStream()) { return new String(input.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { @@ -72,48 +70,7 @@ public class SecuritySqlScriptService { } } - private List parse(String raw, ObjectMapper objectMapper) { - if (raw == null || raw.isBlank()) { - return List.of(); - } - try { - List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); - if (parsed.isEmpty()) { - throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다."); - } - Set scriptIds = new HashSet<>(); - Set fileNames = new HashSet<>(); - parsed.forEach(definition -> { - validate(definition); - if (!scriptIds.add(definition.scriptId())) { - throw new IllegalArgumentException("중복 scriptId"); - } - if (!fileNames.add(definition.fileName())) { - throw new IllegalArgumentException("중복 fileName"); - } - }); - return List.copyOf(parsed); - } catch (Exception exception) { - throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception); - } - } - - private void validate(ScriptDefinition definition) { - if (definition == null - || definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches() - || definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches() - || blank(definition.category()) - || blank(definition.title()) - || blank(definition.description())) { - throw new IllegalArgumentException("보안 SQL 정의가 올바르지 않습니다."); - } - } - - private boolean blank(String value) { - return value == null || value.isBlank(); - } - - public record ScriptDefinition( + private record ScriptDefinition( String scriptId, String category, String fileName, diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java deleted file mode 100644 index bec7d62..0000000 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java +++ /dev/null @@ -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 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); - } -} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java new file mode 100644 index 0000000..dc367c5 --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java @@ -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; + } +} diff --git a/vpd-backoffice/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml index 6824731..b2c1c34 100644 --- a/vpd-backoffice/src/main/resources/application.yml +++ b/vpd-backoffice/src/main/resources/application.yml @@ -69,33 +69,9 @@ backoffice: oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} select-ai: - # Profile-owner connection: SHOWSQL generation only. - db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}} - db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:${BACKOFFICE_DB_USERNAME:}} - db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:${BACKOFFICE_DB_PASSWORD:}} - profile: ${BACKOFFICE_SELECT_AI_PROFILE:} - # Non-EXEMPT execution boundary: no fallback to the profile owner is allowed. - runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:} - runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:} - runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:} - # Customer/project query semantics are external JSON, never Java constants. - query-contract-file: ${BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE:} - 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:} + # 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} diff --git a/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml b/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml index e68c641..306c78c 100644 --- a/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml @@ -3,9 +3,10 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - INSERT INTO hmm_access_audit ( - event_type, key_id, object_id, status, row_count, error_code, message, created_at + INSERT INTO sg_audit_event ( + audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at ) VALUES ( + sg_audit_event_seq.NEXTVAL, #{eventType,jdbcType=VARCHAR}, #{keyId,jdbcType=NUMERIC}, #{objectId,jdbcType=NUMERIC}, diff --git a/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml index 6fd2bdc..23a563c 100644 --- a/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml +++ b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml @@ -68,20 +68,19 @@ --> - SELECT s.user_id AS stakeholder_user_id, - s.user_nm AS username, - s.role, - s.channel, - s.access_scope, + SELECT TO_CHAR(u.user_id) AS stakeholder_user_id, + u.user_name AS username, + COALESCE(( + SELECT MIN(r.role_name) + 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 - FROM poc_2.kb_stakeholders s - JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id + FROM sg_app_user u WHERE u.active = 'Y' - ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END, - s.channel, - s.user_nm, - s.user_id + ORDER BY role, u.dept_code, u.user_name, u.user_id diff --git a/vpd-backoffice/src/main/resources/static/js/app.js b/vpd-backoffice/src/main/resources/static/js/app.js index 4c73aa7..ed95ed1 100644 --- a/vpd-backoffice/src/main/resources/static/js/app.js +++ b/vpd-backoffice/src/main/resources/static/js/app.js @@ -733,16 +733,16 @@ function permissionRuleBusinessLabel(type, column, value) { return `토큰 이해관계자 본인 행 (${displayColumn})`; } if (type === 'OWN_CONTRACT') { - return `담당 설계사 본인 계약 (${displayColumn})`; + return `담당 게임 서비스 범위 (${displayColumn})`; } if (type === 'CHANNEL_CONTRACT') { - return `토큰 사용자 채널 계약 (${displayColumn})`; + return `토큰 채널 게임 서비스 범위 (${displayColumn})`; } if (type === 'OWN_CUSTOMER') { - return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; + return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`; } if (type === 'CHANNEL_CUSTOMER') { - return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; + return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`; } if (type === 'STATIC_SQL') { return `정적 SQL 조건: ${value || '조건식 미입력'}`; @@ -823,10 +823,10 @@ function collectWizardPredicates(root) { return `${displayColumn} = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')`; } 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') { - 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') { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`; @@ -838,10 +838,10 @@ function collectWizardPredicates(root) { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`; } 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') { - 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') { return `${displayColumn} = ${sqlLiteral(value)}`; diff --git a/vpd-backoffice/src/main/resources/templates/masking-rules.html b/vpd-backoffice/src/main/resources/templates/masking-rules.html index e2ef196..228cc26 100644 --- a/vpd-backoffice/src/main/resources/templates/masking-rules.html +++ b/vpd-backoffice/src/main/resources/templates/masking-rules.html @@ -108,8 +108,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼레거시 VPD 컬럼 제어DB 정책 활성상태 판단 - OWNER.OBJECT_NAME - REDACTION_POLICY + SGMP_POC.CZN_COMN_USER_MST + SG_GAME_USER_REDACT 0 0 없음 @@ -142,17 +142,17 @@

컬럼 마스킹 규칙 등록

-

업무용 이름을 붙여 템플릿을 재사용합니다. 예: EMAIL_STANDARD.

+

게임 데이터 기준의 업무용 이름을 붙여 템플릿을 재사용합니다. 예: GAME_USER_ID_MASK.

@@ -253,9 +253,9 @@ 대상 컬럼규칙템플릿백오피스 설정DB ASO 적용 상태 - ADMIN.HMM_HR_EMPLOYEES.EMAIL - 주민번호 기본 마스킹 - 주민등록번호 부분 마스킹 + SGMP_POC.CZN_COMN_USER_MST.USER_ID + 게임 사용자 식별자 기본 마스킹 + 식별자 부분 마스킹 기본 규칙 연결됨 MCP 연동
도움말 -

환경 설정으로 승인한 MCP 도구만 제공합니다. tools/list의 설명과 입력 스키마를 확인한 뒤 사용자 Bearer Token으로 호출하세요.

+

사용자 Bearer Token을 검증한 뒤 SGMP_POC_HAIKU45 Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.

@@ -90,7 +90,9 @@ AGENT_TOOL_OR_SELECT_AI
ORDS 행 접근 조회 도구 설명
- 정확한 argument 이름과 필수 여부는 tools/list.inputSchema를 사용합니다. + + HTTP Authorization → 활성 PoC 사용자 토큰 검증 · prompt → 게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행 + @@ -106,9 +108,9 @@ tools/call parameter 예시 보기

tools/call Arguments

{
-  "<tools/list의 argument 이름>": "<조회할 자연어 질문>"
+  "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
 }
-

도구명, 설명, argument 이름은 배포 환경에서 바뀔 수 있으므로 tools/list 결과를 기준으로 호출합니다.

+

등록 tool은 oracle.select_ai.smilegate_game_text2sql 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 SGMP_POC_HAIKU45가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.

@@ -142,9 +144,9 @@ "id": 3, "method": "tools/call", "params": { - "name": "<tools/list의 name>", + "name": "oracle.select_ai.smilegate_game_text2sql", "arguments": { - "<required argument>": "조회할 자연어 질문" + "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘." } } } diff --git a/vpd-backoffice/src/main/resources/templates/operation-status.html b/vpd-backoffice/src/main/resources/templates/operation-status.html index db64ea6..dd039c9 100644 --- a/vpd-backoffice/src/main/resources/templates/operation-status.html +++ b/vpd-backoffice/src/main/resources/templates/operation-status.html @@ -159,8 +159,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼상태확인 결과 - OWNER.OBJECT_NAME - REDACTION_POLICY + SGMP_POC.COMN_SALES_TXN + SG_SALES_TXN_REDACT 0 0 적용됨 diff --git a/vpd-backoffice/src/main/resources/templates/permissions.html b/vpd-backoffice/src/main/resources/templates/permissions.html index de14fef..d06d4a8 100644 --- a/vpd-backoffice/src/main/resources/templates/permissions.html +++ b/vpd-backoffice/src/main/resources/templates/permissions.html @@ -8,8 +8,8 @@

행 접근 규칙

도움말 -

이 화면은 행 접근 규칙만 저장합니다. HMM 휴가 원장의 저장값은 HMM_LEAVE_VPD_FILTER가 읽어서 대상 테이블의 WHERE predicate로 바꿉니다.

-

팀장은 본인과 직속 팀원, 팀원은 본인 행만 조회하도록 설정합니다. 컬럼 원문/마스킹은 컬럼 마스킹의 ASO/Data Redaction 정책에서 별도로 관리합니다.

+

이 화면은 행 접근 규칙만 저장합니다. 저장값은 최종 SQL이 아니라 CB_AGENT_DOC_VPD_FILTER가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.

+

컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 게임 사용자 식별자, 결제금액 같은 민감 컬럼 표시는 컬럼 마스킹의 ASO/Data Redaction 정책에서 관리합니다.

@@ -157,14 +157,19 @@ @@ -174,7 +179,7 @@
행 규칙의 두 가지 적용 방식 보기 -

표준 범위는 유효한 HMM 토큰에서 만든 직원 context로 치환됩니다. SELF는 현재 직원의 행만, MANAGED_TEAM은 현재 직원과 MANAGER_EMPLOYEE_ID로 연결된 직접 보고자의 행만 남깁니다. 정적 SQL 조건식은 현재 객체 컬럼을 사용한 고정 WHERE 조건을 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.

+

조건 코드는 토큰 context와 DB에 등록된 업무 관계를 바탕으로 행 접근 함수가 해석합니다. 관계가 없는 게임 데이터 객체에는 조건 코드를 억지로 적용하지 말고, 정적 SQL 조건식으로 GAME_ID = 'CZN'처럼 실제 컬럼을 사용하세요. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.

컬럼 원문/마스킹은 제외했습니다. 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.

@@ -190,19 +195,29 @@ 1 = 1 - SELF - 토큰으로 식별된 HMM 직원 본인 행 - EMPLOYEE_ID = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID') + TOKEN_SUBJECT + 토큰으로 식별된 운영 사용자 범위 + 대상 객체가 토큰 주체 식별 컬럼을 가질 때만 DB 행 접근 함수가 생성 - MANAGED_TEAM - 팀장 본인과 직접 보고 팀원의 행 - EMPLOYEE_ID IN (현재 직원 및 MANAGER_EMPLOYEE_ID가 현재 직원인 직원) + OWN_CONTRACT + 토큰 사용자가 담당하는 게임 서비스 범위 + 등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성 + + + CHANNEL_CONTRACT + 토큰 채널에 속한 게임 서비스 범위 + 등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성 + + + OWN_CUSTOMER / CHANNEL_CUSTOMER + 게임 서비스·사용자 기준으로 연결되는 게임 로그·판매·환불 데이터 범위 + 대상 테이블의 실제 키 관계를 DB 행 접근 함수가 검증한 뒤 생성 STATIC_SQL 현재 객체 컬럼으로 표현한 고정 조건 - REQUEST_STATUS = 'PENDING'처럼 검증된 현재 객체 컬럼 조건 + GAME_ID = 'CZN'처럼 검증된 현재 객체 컬럼 조건 diff --git a/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html b/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html index 01157fa..4d58a18 100644 --- a/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html +++ b/vpd-backoffice/src/main/resources/templates/security-sql-scripts.html @@ -29,9 +29,9 @@ ASO / 마스킹 - 62_kb_aso_masking_backoffice_metadata.sql + 71_sg_identity_administration.sql - 컬럼 마스킹 규칙 메타데이터 + 사용자·그룹·역할 관리 모델
설명
원문 보기 @@ -45,13 +45,13 @@
- ASO / 마스킹 -

컬럼 마스킹 규칙 메타데이터

+ Smilegate 권한 +

사용자·그룹·역할 관리 모델

설명

- 62_kb_aso_masking_backoffice_metadata.sql + 71_sg_identity_administration.sql
-

Git source: database/adb/62_kb_aso_masking_backoffice_metadata.sql. 실제 DB 배포본은 행 접근 필터 구조 및 DB 배포 이력과 함께 확인하세요.

+

Git source: sql/adb/71_sg_identity_administration.sql. 실제 DB 배포본은 행 접근 필터 구조 및 DB 배포 이력과 함께 확인하세요.

diff --git a/vpd-backoffice/src/main/resources/templates/tokens.html b/vpd-backoffice/src/main/resources/templates/tokens.html index d787dcc..4ed66c4 100644 --- a/vpd-backoffice/src/main/resources/templates/tokens.html +++ b/vpd-backoffice/src/main/resources/templates/tokens.html @@ -1,14 +1,14 @@ - +
-

HMM 액세스 토큰

+

Smilegate 액세스 토큰

도움말 -

HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.

+

Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.

@@ -27,14 +27,14 @@
-

직원 토큰 발급

-

활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.

+

도구 사용자 토큰 발급

+

활성 Data & AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.

diff --git a/vpd-backoffice/src/main/resources/templates/user-masking-rules.html b/vpd-backoffice/src/main/resources/templates/user-masking-rules.html index 83f0fa5..749f854 100644 --- a/vpd-backoffice/src/main/resources/templates/user-masking-rules.html +++ b/vpd-backoffice/src/main/resources/templates/user-masking-rules.html @@ -26,7 +26,7 @@
@@ -85,9 +85,9 @@ 사용자대상 컬럼기본 규칙원문 표시 상태상태 - demo-user - OWNER.OBJECT_NAME.COLUMN_NAME - 주민번호 기본 마스킹 · 주민등록번호 부분 마스킹 + sg-teamlead + SGMP_POC.CZN_COMN_USER_MST.GUID + 게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹 원문 표시 예외 Y diff --git a/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html b/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html index c1238f5..d35bc2e 100644 --- a/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html +++ b/vpd-backoffice/src/main/resources/templates/vpd-filter-runtime.html @@ -82,8 +82,8 @@ - OWNER.OBJECT_NAME - ROW_ACCESS_POLICY + SGMP_POC.CZN_COMN_USER_MST + SG_CZN_USER_ROW_POLICY SELECT 적용됨 ADMIN.CB_AGENT_DOC_VPD_FILTER @@ -105,7 +105,7 @@ 토큰 신뢰 경계Bearer Token은 DB 패키지에서 해시·만료·회수·재직 상태를 검증합니다.HMM_ACCESS_CTX_PKG 권한 결합권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.행 접근 규칙 오류·미권한유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.접근 검증 - 컬럼 보호행 필터(VPD)와 개인정보 ASO/Data Redaction 마스킹을 분리해 확인합니다.컬럼 마스킹 + 컬럼 보호행 필터(VPD)와 게임 사용자 식별자·거래 식별자의 ASO/Data Redaction 마스킹을 분리해 확인합니다.컬럼 마스킹
diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java index 96ce232..73e3ff6 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java @@ -1,14 +1,8 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import com.cloudhandson.vpdbackoffice.config.CatalogProperties; -import com.cloudhandson.vpdbackoffice.config.MaskingProperties; -import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; class MaskingPolicySynchronizerTest { @@ -29,29 +23,8 @@ class MaskingPolicySynchronizerTest { } @Test - void hmmEmployeePolicyComesFromEnvironmentPolicyCatalog() { - ObjectMapper objectMapper = new ObjectMapper(); - var dataCatalog = new EnvironmentDataCatalog( - new CatalogProperties("ADMIN", """ - [{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE", - "businessName":"직원","description":"직원"}] - """), - objectMapper); - var policyCatalog = new EnvironmentMaskingPolicyCatalog( - new MaskingProperties(""" - [{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"}] - """), - objectMapper); - var synchronizer = new MaskingPolicySynchronizer( - mock(JdbcTemplate.class), - mock(MaskingRuleMapper.class), - dataCatalog, - policyCatalog); - - assertThat(synchronizer.owner()).isEqualTo("ADMIN"); - assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES")) - .isEqualTo("HMM_EMPLOYEE_PII_REDACT"); - assertThat(synchronizer.managedObjectNames()) - .doesNotContain("KB_CUSTOMERS", "KB_CLAIMS", "KB_CONTRACTS"); + void smilegateSalesTransactionPolicyIsManagedByBackoffice() { + assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN")) + .isEqualTo("SG_SALES_TXN_REDACT"); } } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index d08dc3c..d312f0f 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -1,208 +1,71 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; -import com.cloudhandson.vpdbackoffice.config.McpProperties; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Test; class McpSseServiceTest { - private static final String HMM_TOOLS = """ - [ - { - "name":"resolve_hr_term", - "label":"HMM HR 용어 표준화", - "description":"휴가·근태 표현을 표준 용어와 코드로 변환합니다.", - "argumentName":"term", - "argumentDescription":"확인할 휴가·근태 용어입니다.", - "executionType":"AGENT_TOOL", - "targetName":"HMM_HR_TERM_RESOLVER", - "targetParameterName":"P_TERM" - }, - { - "name":"search_hr_data", - "label":"HMM HR 데이터 조회", - "description":"조직, 직원, 휴가, 근태 데이터를 조회합니다.", - "argumentName":"query", - "argumentDescription":"완전한 자연어 질문입니다.", - "executionType":"AGENT_TOOL", - "targetName":"HMM_HR_NORMALIZED_DATA_SEARCH", - "targetParameterName":"P_QUERY" - }, - { - "name":"search_hr_policy", - "label":"HMM HR 규정 검색", - "description":"HR 규정 PDF를 검색합니다.", - "argumentName":"query", - "argumentDescription":"정책에 대한 자연어 질문입니다.", - "executionType":"AGENT_TOOL", - "targetName":"HMM_HR_POLICY_SEARCH", - "targetParameterName":"P_QUERY" - } - ] - """; - private final ObjectMapper objectMapper = new ObjectMapper(); - private final McpProperties mcpProperties = - new McpProperties( - "https://example.com/mcp", - "hmm-hr-backoffice", - "", - "", - "", - "", - HMM_TOOLS); - private final McpToolCatalog toolCatalog = - new EnvironmentMcpToolCatalog(mcpProperties, objectMapper); - private final CapturingHmmAiAgentToolRunner agentToolRunner = - new CapturingHmmAiAgentToolRunner(); - private final HmmMcpBearerAuthenticator bearerAuthenticator = - token -> new HmmMcpPrincipal(1L, "E1001", 1L); + private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService(); private final McpSseService service = new McpSseService( - agentToolRunner, - mock(SelectAiService.class), - bearerAuthenticator, - toolCatalog, - mcpProperties, - new BackofficeProperties(null, null, null, null, null), - objectMapper); + selectAiService, + objectMapper + ); @Test - void listsEnvironmentConfiguredTermDataAndPolicyTools() { - ObjectNode response = - service.handle("default", request(1, "tools/list"), "valid-token"); + void listsOnlyVpdSelectAiToolWithPromptInput() { + ObjectNode response = service.handle("default", request(1, "tools/list")); var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(3); - assertThat(tools).extracting(node -> node.path("name").asText()) - .containsExactly("resolve_hr_term", "search_hr_data", "search_hr_policy"); - assertThat(tools.get(0).path("inputSchema").path("required")) - .extracting(JsonNode::asText) - .containsExactly("term"); - assertThat(tools.get(1).path("inputSchema").path("required")) - .extracting(JsonNode::asText) - .containsExactly("query"); + assertThat(tools).hasSize(1); + var selectAi = tools.get(0); + assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql"); + assertThat(selectAi.path("inputSchema").path("required")) + .extracting(node -> node.asText()) + .contains("prompt"); + assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); + assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse(); + assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); } @Test - void callsConfiguredAgentToolWithItsDeclaredInputName() { + void callsVpdSelectAiThroughOrdsService() { ObjectNode request = request(2, "tools/call"); - ObjectNode params = request.putObject("params"); - params.put("name", "resolve_hr_term"); - params.putObject("arguments").put("term", "연차 이월"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "oracle.select_ai.smilegate_game_text2sql"); + ObjectNode arguments = params.putObject("arguments"); + arguments.put("prompt", "카제나 AU를 조회해 줘"); - ObjectNode response = service.handle("default", request, "valid-token"); + ObjectNode response = service.handle("default", request, "user-bearer"); - assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER"); - assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월"); - assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token"); + CapturingSmilegateSelectAiService agentService = + (CapturingSmilegateSelectAiService) selectAiService; + assertThat(agentService.bearerToken).isEqualTo("user-bearer"); + assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); - } - - @Test - void routesSelectAiToolThroughTheVpdAwareExecutor() { - String selectAiTools = """ - [ - { - "name":"search_hr_data", - "label":"HMM HR 데이터 조회", - "description":"VPD가 적용된 HMM HR 데이터를 조회합니다.", - "argumentName":"query", - "argumentDescription":"완전한 자연어 질문입니다.", - "executionType":"SELECT_AI" - } - ] - """; - McpProperties selectAiProperties = - new McpProperties("", "", "", "", "", "", selectAiTools); - SelectAiService selectAiService = mock(SelectAiService.class); - when(selectAiService.generateAndExecute( - "valid-token", "내 휴가 신청 내역을 보여줘")) - .thenReturn(objectMapper.createObjectNode().put("vpdEnforced", true)); - McpSseService selectAiMcp = new McpSseService( - agentToolRunner, - selectAiService, - bearerAuthenticator, - new EnvironmentMcpToolCatalog(selectAiProperties, objectMapper), - selectAiProperties, - new BackofficeProperties(null, null, null, null, null), - objectMapper); - ObjectNode request = request(5, "tools/call"); - request.putObject("params") - .put("name", "search_hr_data") - .putObject("arguments") - .put("query", "내 휴가 신청 내역을 보여줘"); - - ObjectNode response = - selectAiMcp.handle("default", request, "valid-token"); - - verify(selectAiService).generateAndExecute( - "valid-token", "내 휴가 신청 내역을 보여줘"); - assertThat(response.path("result").path("isError").asBoolean()).isFalse(); assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("\"vpdEnforced\" : true"); + .contains("SGMP_POC_HAIKU45") + .contains("SELECT 1 FROM DUAL"); } @Test - void rejectsDiscoveryWhenBearerAuthenticationFails() { - McpSseService rejectingService = new McpSseService( - agentToolRunner, - mock(SelectAiService.class), - token -> { - throw new McpUnauthorizedException(); - }, - toolCatalog, - mcpProperties, - new BackofficeProperties(null, null, null, null, null), - objectMapper); - - assertThatThrownBy(() -> - rejectingService.handle("default", request(4, "tools/list"), "invalid-token")) - .isInstanceOf(McpUnauthorizedException.class); - } - - @Test - void rejectsUnknownToolsWithoutCallingTheRunner() { + void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { ObjectNode request = request(3, "tools/call"); - ObjectNode params = request.putObject("params"); - params.put("name", "unconfigured.tool"); - params.putObject("arguments").put("prompt", "query"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "oracle.select_ai.smilegate_game_text2sql"); + params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘"); - ObjectNode response = service.handle("default", request, "valid-token"); + ObjectNode response = service.handle("default", request, ""); - assertThat(response.path("result").isMissingNode()).isTrue(); - 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"); + assertThat(response.path("error").isMissingNode()).isTrue(); + assertThat(response.path("result").path("isError").asBoolean()).isTrue(); + assertThat(response.path("result").path("content").get(0).path("text").asText()) + .contains("VPD_TOKEN_DENIED") + .contains("권한이 없습니다"); } private ObjectNode request(int id, String method) { @@ -213,23 +76,22 @@ class McpSseServiceTest { return request; } - private final class CapturingHmmAiAgentToolRunner - implements HmmAiAgentToolRunner { + private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService { - private String toolName; - private ObjectNode input; private String bearerToken; + private String prompt; + + private CapturingSmilegateSelectAiService() { + super(null, null, null, new ObjectMapper()); + } @Override - public JsonNode run( - String requestedToolName, - ObjectNode requestedInput, - String bearerToken - ) { - toolName = requestedToolName; - input = requestedInput.deepCopy(); + public JsonNode generateShowSql(String bearerToken, String prompt) { this.bearerToken = bearerToken; - return objectMapper.createObjectNode().put("status", "ok"); + this.prompt = prompt; + return new ObjectMapper().createObjectNode() + .put("profile", "SGMP_POC_HAIKU45") + .put("generatedSql", "SELECT 1 FROM DUAL"); } } } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java index 3d8c073..f738c2a 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java @@ -3,51 +3,30 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; class SecuritySqlScriptServiceTest { - private final SecuritySqlScriptService service = new SecuritySqlScriptService( - new SecuritySqlScriptProperties(""" - [{ - "scriptId":"hmm-leave-vpd", - "category":"HMM / VPD", - "fileName":"72_hmm_leave_team_vpd.sql", - "title":"HMM 휴가 팀 접근 정책", - "description":"팀장과 팀원 휴가 행 접근 정책" - }] - """), - new ObjectMapper()); + private final SecuritySqlScriptService service = new SecuritySqlScriptService(); @Test - void exposesOnlyEnvironmentAllowlistedBundledScripts() { + void exposesOnlyTheCuratedGitTrackedSecurityScripts() { assertThat(service.list()) .extracting(item -> item.fileName()) - .containsExactly("72_hmm_leave_team_vpd.sql"); + .containsExactly( + "70_sg_tool_user.sql", + "71_sg_identity_administration.sql" + ); - assertThat(service.find("hmm-leave-vpd").source()) - .contains("HMM_ACCESS_CTX_PKG") - .contains("HMM_LEAVE_VPD_FILTER"); + assertThat(service.find("smilegate-identity-administration").source()) + .contains("create table sg_app_user") + .contains("DATA_AI_TF"); } @Test - void rejectsUnknownScriptIdsInsteadOfResolvingARequestPath() { + void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() { assertThatThrownBy(() -> service.find("../../etc/passwd")) .isInstanceOf(AppException.class) .hasMessageContaining("조회할 수 없는"); } - - @Test - void rejectsUnsafeResourcePathsAtStartup() { - assertThatThrownBy(() -> new SecuritySqlScriptService( - new SecuritySqlScriptProperties(""" - [{"scriptId":"bad","category":"x","fileName":"../../etc/passwd.sql", - "title":"x","description":"x"}] - """), - new ObjectMapper())) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("BACKOFFICE_SECURITY_SQL_SCRIPTS"); - } } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java index afa00bf..a0dc7dd 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java @@ -4,72 +4,31 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; -import com.cloudhandson.vpdbackoffice.config.CatalogProperties; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; class StructuredDataServiceTest { - private static final String HMM_OBJECTS = """ - [ - {"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE", - "businessName":"조직 원장","description":"조직 정보"}, - {"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE", - "businessName":"직원 원장","description":"직원 정보"}, - {"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"VIEW", - "businessName":"휴가 잔여 원장","description":"휴가 잔여 정보"} - ] - """; - - private final DataCatalog catalog = new EnvironmentDataCatalog( - new CatalogProperties("ADMIN", HMM_OBJECTS), new ObjectMapper()); - private final StructuredDataService service = - new StructuredDataService(mock(JdbcTemplate.class), catalog); + private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); @Test - void exposesOnlyEnvironmentConfiguredTablesAndViews() { - assertThat(service.owner()).isEqualTo("ADMIN"); + void exposesOnlyTheSevenApprovedSmilegateStructuredTables() { assertThat(service.tables()) .extracting(table -> table.tableName()) .containsExactly( - "HMM_ORG_TEAMS", - "HMM_HR_EMPLOYEES", - "HMM_LEAVE_BALANCES"); - assertThat(service.requireTable("leave-balances").objectType()).isEqualTo("VIEW"); - assertThat(service.previewSql(service.requireTable("employees"))) - .isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?"); + "CZN_COMN_USER_MST", + "CZN_COMN_CHARACTER_MST", + "COMN_SALES_TXN", + "COMN_REFUND_TXN", + "COMN_SALES_PRODUCT_DISP_BAS", + "COMN_GAME_SERVER_BAS", + "COMN_GAME_ALIAS_BAS"); } @Test - void rejectsAnyObjectOutsideTheServerSideAllowlist() { - assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG")) + void rejectsAnyTableOutsideTheServerSideAllowlist() { + assertThatThrownBy(() -> service.requireTable("security-audit-log")) .isInstanceOf(AppException.class) - .hasMessage("선택할 수 없는 카탈로그 객체입니다."); - } - - @Test - void rejectsUnsafeOracleIdentifiers() { - assertThatThrownBy(() -> new EnvironmentDataCatalog( - new CatalogProperties("ADMIN; DROP USER X", HMM_OBJECTS), new ObjectMapper())) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("BACKOFFICE_CATALOG_OWNER"); - } - - @Test - void rejectsDuplicateObjectNamesEvenWhenKeysDiffer() { - String duplicate = """ - [ - {"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE", - "businessName":"직원","description":"직원"}, - {"key":"workers","tableName":"HMM_HR_EMPLOYEES","objectType":"VIEW", - "businessName":"직원 뷰","description":"직원 뷰"} - ] - """; - - assertThatThrownBy(() -> new EnvironmentDataCatalog( - new CatalogProperties("ADMIN", duplicate), new ObjectMapper())) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS"); + .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); } } diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index df876dd..08d1e26 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -35,11 +35,25 @@ class GuidedFlowTemplateTest { .doesNotContain("dashboard-workflow") .doesNotContain(">01<", ">02<"); assertThat(layout) - .contains("Data & AI Backoffice") - .contains("사용자") - .contains("접근 그룹") - .contains("역할") - .contains("토큰") + .contains("운영 사용자 관리") + .contains("접근 제어") + .contains("보호·검증") + .contains("연동 도구") + .contains("운영") + .contains("관리자") + .contains("조회 연동") + .contains("정형 데이터 조회") + .contains("MCP 서비스") + .contains("컬럼 마스킹") + .contains("컬럼 원문 표시 허용") + .contains("행 접근 필터 구조") + .contains("보안 SQL 스크립트") + .contains("고급 접근 조건") + .contains("시스템 설정") + .contains("DB 준비 상태") + .contains("data-submenu-trigger") + .contains("data-submenu-bar") + .contains("data-submenu-panel") .contains("backoffice-can-mutate") .contains("backoffice-read-only") .doesNotContain("VPD Backoffice"); @@ -108,7 +122,7 @@ class GuidedFlowTemplateTest { .contains("data-wizard-target=\"2\">대상") .doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number"); assertThat(tokens) - .contains("직원 토큰 발급", "검증 세션 발급") + .contains("도구 사용자 토큰 발급", "검증 세션 발급") .doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급"); assertThat(probe) .contains("접근 검증 실행", "검증 결과") @@ -184,8 +198,7 @@ class GuidedFlowTemplateTest { String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js")); assertThat(html) - .contains("value=\"MANAGED_TEAM\">본인 및 직접 보고 팀원") - .contains("value=\"SELF\">본인 직원 행") + .contains("value=\"OWN_CUSTOMER\">담당 게임 사용자") .contains("value=\"STATIC_SQL\">정적 SQL 조건식") .contains("HMM_LEAVE_VPD_FILTER") .contains("행 규칙의 두 가지 적용 방식 보기") @@ -209,6 +222,7 @@ class GuidedFlowTemplateTest { String filters = template("vpd-filter-policies.html"); assertThat(roles) + .contains("스마일게이트 Data & AI PoC 운영 역할") .contains("데이터 취급 등급"); assertThat(tokens) .contains("회수·만료 포함") diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java index 8fae5e3..211f3ca 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java @@ -31,26 +31,26 @@ class MaskingRuleTemplateRenderTest { var engine = new SpringTemplateEngine(); engine.setTemplateResolver(resolver); - var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y"); - var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y"); - var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY"); - var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y"); - var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED", - 1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y"); + var object = new ProtectedObject(7L, "SGMP_POC", "CZN_COMN_USER_MST", "sgmp-poc/czn-users", "Y"); + var transactionObject = new ProtectedObject(8L, "SGMP_POC", "COMN_SALES_TXN", "sgmp-poc/sales", "Y"); + var column = new ProtectedColumn(11L, 7L, "GUID", "Y", null, "RESTRICTED", "NULLIFY"); + var rule = new MaskingRule(1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "테스트", "Y"); + var columnRule = new ColumnMaskingRule(11L, 7L, "SGMP_POC", "CZN_COMN_USER_MST", "GUID", + 1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "Y"); var context = new Context(Locale.KOREAN); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("templates", List.of(MaskingTemplate.values())); context.setVariable("rules", List.of(rule)); context.setVariable("columnRules", List.of(columnRule)); 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("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status)); - context.setVariable("objects", List.of(object, contractObject)); - context.setVariable("maskingTargetObjects", List.of(object, contractObject)); + context.setVariable("policyStatusByObjectName", Map.of("CZN_COMN_USER_MST", status)); + context.setVariable("objects", List.of(object, transactionObject)); + context.setVariable("maskingTargetObjects", List.of(object, transactionObject)); 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); @@ -64,13 +64,13 @@ class MaskingRuleTemplateRenderTest { .contains("DB Redaction 컬럼") .contains("레거시 VPD 컬럼 제어") .contains("마스킹 대상 컬럼 추가") - .contains("POC_2.KB_CONTRACTS.PREMIUM") - .contains("KB_CUSTOMER_PII_REDACT") + .contains("SGMP_POC.COMN_SALES_TXN.GUID") + .contains("SG_CZN_USER_REDACT") .contains("적용됨"); - context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y"))); - context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L, - "POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "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, "sg-teamlead", 11L, + "SGMP_POC", "CZN_COMN_USER_MST", "GUID", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "UNMASK", "Y"))); String userPage = engine.process("user-masking-rules", context); assertThat(userPage)