diff --git a/ai-web-agent-console/.env.example b/ai-web-agent-console/.env.example index e3d01e8..c6cb864 100644 --- a/ai-web-agent-console/.env.example +++ b/ai-web-agent-console/.env.example @@ -3,6 +3,11 @@ # MCP HMM_MCP_BEARER_TOKEN= +HMM_MCP_BEARER_TOKEN_E1001= +HMM_MCP_BEARER_TOKEN_E1002= +HMM_MCP_BEARER_TOKEN_E1003= +HMM_MCP_BEARER_TOKEN_E1005= +HMM_MCP_BEARER_TOKEN_E1007= AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45 # OCI Generative AI SDK diff --git a/ai-web-agent-console/README.md b/ai-web-agent-console/README.md index 799316d..a4e99d9 100644 --- a/ai-web-agent-console/README.md +++ b/ai-web-agent-console/README.md @@ -41,7 +41,9 @@ python3 -m venv .venv - `config/hmm_hr_query_contracts.json`: 질의별 필수 근거와 계산·시간 규칙 실제 토큰, DB 비밀번호, Wallet, OCI private key는 Git에 넣지 않는다. 데모 사용자 JSON은 -`HMM_MCP_BEARER_TOKEN` 같은 환경변수 이름만 참조한다. +`HMM_MCP_BEARER_TOKEN_E1001`처럼 사용자별 환경변수 이름만 참조한다. token 원문은 설정 JSON이나 +문서에 기록하지 않는다. 사용자 preset들이 같은 환경변수를 공유하면 화면의 사용자만 바뀌고 DB +권한 문맥은 바뀌지 않으므로 허용하지 않는다. ## 검증 diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py index b262d4b..41fd3a1 100644 --- a/ai-web-agent-console/app.py +++ b/ai-web-agent-console/app.py @@ -2864,10 +2864,10 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str: def _camel_case_key(value: object) -> str: - text = str(value or "") - if "_" not in text: + text = str(value or "").strip() + if not re.search(r"[\s_-]", text): return text[:1].lower() + text[1:] - parts = [part for part in text.casefold().split("_") if part] + parts = [part for part in re.split(r"[\s_-]+", text.casefold()) if part] return ( parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:]) if parts @@ -2897,11 +2897,11 @@ def _clean_presentation_title(value: Any) -> str: def _fallback_presentation_data_query(question: str) -> str: text = " ".join(str(question or "").split()).strip() text = re.sub( - r"\s*(?:그리고\s*)?(?:이걸|이를|그\s*결과를|결과를)\s*" + r"\s*(?:그리고\s*)?(?:(?:이걸|이를|그\s*결과를|결과를)\s*)?" r"(?:HMM\s*)?(?:HTML|리포트|보고서|대시보드|차트)\s*" r"(?:형식)?(?:으로|로)?\s*(?:만들어|생성해|작성해|보여)\s*" r"(?:줘|주세요)?\s*[.!?]?\s*$", - "", + " 보여줘", text, flags=re.IGNORECASE, ) @@ -2912,7 +2912,7 @@ def _fallback_presentation_data_query(question: str) -> str: ) text = re.sub( r"(?:HMM\s*)?(?:리포트|보고서|대시보드|차트)\s*" - r"(?:형식)?(?:으로|로)?\s*(?:만들어|생성해|작성해)\s*" + r"(?:형식)?(?:으로|로)?\s*(?:만들어|생성해|작성해|보여)\s*" r"(?:줘|주세요)?", "보여줘", text, diff --git a/ai-web-agent-console/config/vpd_token_presets.json b/ai-web-agent-console/config/vpd_token_presets.json index ecd510d..0482228 100644 --- a/ai-web-agent-console/config/vpd_token_presets.json +++ b/ai-web-agent-console/config/vpd_token_presets.json @@ -5,7 +5,7 @@ { "enabled": true, "default": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1001", "user_id": "E1001", "name": "Kim Minseo", "role": "HR Team Manager", @@ -14,7 +14,7 @@ }, { "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1002", "user_id": "E1002", "name": "Lee Jiwon", "role": "HR Operations Specialist", @@ -23,7 +23,7 @@ }, { "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1003", "user_id": "E1003", "name": "Park Dohyun", "role": "People Analytics Analyst", @@ -32,7 +32,7 @@ }, { "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1005", "user_id": "E1005", "name": "Han Seojun", "role": "Recruiting Specialist", @@ -41,7 +41,7 @@ }, { "enabled": true, - "mcp_token_env": "HMM_MCP_BEARER_TOKEN", + "mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1007", "user_id": "E1007", "name": "Kang Minho", "role": "HR Coordinator", diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py index ac92dee..988c968 100644 --- a/ai-web-agent-console/tests/test_scenarios.py +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -176,6 +176,32 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(len(rows), 2) self.assertEqual(rows[0]["CARRIER_CODE"], "C901") + def test_hmm_report_normalizes_repeated_select_ai_column_labels(self) -> None: + source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") + tree = ast.parse(source) + helpers = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name in {"_camel_case_key", "_normalize_presentation_value"} + ] + namespace: dict[str, Any] = { + "Any": Any, + "Mapping": Mapping, + "re": re, + } + exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace) + + first = namespace["_normalize_presentation_value"]( + {"CARRIER_CODE": "C001", "LATEST_REVENUE_USD": 100} + ) + repeated = namespace["_normalize_presentation_value"]( + {"carrier Code": "C002", "LATEST REVENUE USD": 200} + ) + + self.assertEqual(first, {"carrierCode": "C001", "latestRevenueUsd": 100}) + self.assertEqual(repeated, {"carrierCode": "C002", "latestRevenueUsd": 200}) + def test_hmm_report_title_and_answer_follow_presentation_contract(self) -> None: source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8") tree = ast.parse(source) @@ -235,6 +261,11 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertIn("위험 등급", query) self.assertIn("_mcp_rows_contain_presentation_markup(mcp_result)", source) + personal_query = namespace["_fallback_presentation_data_query"]( + "내 담당 선사와 최신 매출을 리포트로 보여줘" + ) + self.assertEqual(personal_query, "내 담당 선사와 최신 매출을 보여줘") + def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None: template = ( Path(__file__).parents[1] @@ -255,7 +286,10 @@ class DemoScenarioConfigTest(unittest.TestCase): 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.assertEqual( + {item["mcp_token_env"] for item in presets}, + {f"HMM_MCP_BEARER_TOKEN_{item['user_id']}" for item in presets}, + ) self.assertTrue(all("token" not in item for item in presets)) def test_duplicate_id_is_rejected(self) -> None: diff --git a/database/adb/84_hmm_carrier_team_vpd.sql b/database/adb/84_hmm_carrier_team_vpd.sql new file mode 100644 index 0000000..536ec2a --- /dev/null +++ b/database/adb/84_hmm_carrier_team_vpd.sql @@ -0,0 +1,323 @@ +-- ============================================================ +-- 84_hmm_carrier_team_vpd.sql +-- +-- Row security for HMM_CARRIER_ASSIGNMENTS_V: +-- HMM_HR_VIEWER -> current employee only +-- HMM_HR_MANAGER -> current employee and direct reports +-- HMM_HR_ADMIN -> all assignments +-- +-- Run as ADMIN after 72_hmm_leave_team_vpd.sql and +-- 81_hmm_carrier_access_groups.sql. Plain bearer tokens are +-- never stored by this script. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === 1. Registering the protected carrier object === +DECLARE + v_object_id NUMBER; +BEGIN + BEGIN + SELECT object_id + INTO v_object_id + FROM hmm_access_objects + WHERE owner = 'ADMIN' + AND object_name = 'HMM_CARRIER_ASSIGNMENTS_V'; + + UPDATE hmm_access_objects + SET enabled_yn = 'Y', + description = 'Employee-to-carrier assignments protected by employee hierarchy', + ords_path = 'cb-ords/cb-object-query/admin/hmm_carrier_assignments_v' + WHERE object_id = v_object_id; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT NVL(MAX(object_id), 0) + 1 + INTO v_object_id + FROM hmm_access_objects; + + INSERT INTO hmm_access_objects ( + object_id, owner, object_name, ords_path, enabled_yn, description + ) VALUES ( + v_object_id, + 'ADMIN', + 'HMM_CARRIER_ASSIGNMENTS_V', + 'cb-ords/cb-object-query/admin/hmm_carrier_assignments_v', + 'Y', + 'Employee-to-carrier assignments protected by employee hierarchy' + ); + END; + + FOR c IN ( + SELECT column_name + FROM user_tab_columns + WHERE table_name = 'HMM_CARRIER_ASSIGNMENTS_V' + ORDER BY column_id + ) LOOP + MERGE INTO hmm_access_object_columns dst + USING ( + SELECT v_object_id object_id, c.column_name column_name FROM dual + ) src + ON (dst.object_id = src.object_id AND dst.column_name = src.column_name) + WHEN NOT MATCHED THEN INSERT ( + column_id, object_id, column_name, sensitive_yn, + sensitivity_level, redaction_method + ) VALUES ( + (SELECT NVL(MAX(column_id), 0) + 1 FROM hmm_access_object_columns), + src.object_id, src.column_name, 'N', 'PUBLIC', 'NONE' + ); + END LOOP; +END; +/ + +PROMPT === 2. Registering role rules === +DECLARE + PROCEDURE ensure_permission( + p_role_name IN VARCHAR2, + p_rule_type IN VARCHAR2 + ) AS + v_role_id NUMBER; + v_perm_id NUMBER; + BEGIN + SELECT role_id + INTO v_role_id + FROM hmm_access_roles + WHERE role_name = p_role_name + AND active_yn = 'Y'; + + BEGIN + SELECT perm_id + INTO v_perm_id + FROM hmm_access_permissions + WHERE role_id = v_role_id + AND target_name = 'HMM_CARRIER_ASSIGNMENTS_V' + AND action_name = 'SELECT'; + + UPDATE hmm_access_permissions + SET permission_effect = 'ALLOW' + WHERE perm_id = v_perm_id; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT cb_permission_seq.NEXTVAL INTO v_perm_id FROM dual; + INSERT INTO hmm_access_permissions ( + perm_id, role_id, target_name, action_name, permission_effect + ) VALUES ( + v_perm_id, v_role_id, 'HMM_CARRIER_ASSIGNMENTS_V', 'SELECT', 'ALLOW' + ); + END; + + DELETE FROM hmm_access_permission_rules WHERE perm_id = v_perm_id; + INSERT INTO hmm_access_permission_rules ( + rule_id, perm_id, rule_column, rule_type, rule_value + ) VALUES ( + cb_permission_rule_seq.NEXTVAL, + v_perm_id, + 'EMPLOYEE_ID', + p_rule_type, + NULL + ); + END; +BEGIN + ensure_permission('HMM_HR_VIEWER', 'SELF'); + ensure_permission('HMM_HR_MANAGER', 'MANAGED_TEAM'); + ensure_permission('HMM_HR_ADMIN', 'ALL'); +END; +/ + +COMMIT; + +PROMPT === 3. Creating the carrier VPD predicate === +CREATE OR REPLACE FUNCTION hmm_carrier_vpd_filter( + p_schema IN VARCHAR2, + p_object IN VARCHAR2 +) RETURN VARCHAR2 +AUTHID DEFINER +AS + v_employee_id NUMBER; + v_all_rule NUMBER := 0; + v_team_rule NUMBER := 0; + v_self_rule NUMBER := 0; +BEGIN + IF UPPER(TRIM(p_schema)) != 'ADMIN' + OR UPPER(TRIM(p_object)) != 'HMM_CARRIER_ASSIGNMENTS_V' THEN + RETURN '1 = 0'; + END IF; + + BEGIN + v_employee_id := TO_NUMBER( + SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID') + ); + EXCEPTION + WHEN OTHERS THEN + RETURN '1 = 0'; + END; + + IF v_employee_id IS NULL THEN + RETURN '1 = 0'; + END IF; + + SELECT NVL(MAX(CASE WHEN rule.rule_type = 'ALL' THEN 1 ELSE 0 END), 0), + NVL(MAX(CASE WHEN rule.rule_type = 'MANAGED_TEAM' THEN 1 ELSE 0 END), 0), + NVL(MAX(CASE WHEN rule.rule_type = 'SELF' THEN 1 ELSE 0 END), 0) + INTO v_all_rule, v_team_rule, v_self_rule + FROM ( + SELECT employee_role.role_id + FROM hmm_employee_access_roles employee_role + WHERE employee_role.employee_id = v_employee_id + UNION + SELECT group_role.role_id + FROM hmm_access_group_members group_member + JOIN hmm_access_groups access_group + ON access_group.group_id = group_member.group_id + AND access_group.active_yn = 'Y' + JOIN hmm_group_access_roles group_role + ON group_role.group_id = group_member.group_id + WHERE group_member.employee_id = v_employee_id + ) effective_role + JOIN hmm_access_roles role + ON role.role_id = effective_role.role_id + AND role.active_yn = 'Y' + JOIN hmm_access_permissions permission + ON permission.role_id = role.role_id + AND permission.target_name = 'HMM_CARRIER_ASSIGNMENTS_V' + AND permission.action_name = 'SELECT' + AND permission.permission_effect = 'ALLOW' + JOIN hmm_access_permission_rules rule + ON rule.perm_id = permission.perm_id + AND UPPER(TRIM(rule.rule_column)) = 'EMPLOYEE_ID'; + + IF v_all_rule = 1 THEN + RETURN '1 = 1'; + END IF; + + IF v_team_rule = 1 THEN + RETURN 'EMPLOYEE_ID IN (' + || 'SELECT employee.employee_id ' + || 'FROM ADMIN.HMM_HR_EMPLOYEES employee ' + || 'WHERE employee.employee_id = ' + || 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID'')) ' + || 'OR employee.manager_employee_id = ' + || 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))' + || ')'; + END IF; + + IF v_self_rule = 1 THEN + RETURN 'EMPLOYEE_ID = TO_NUMBER(' + || 'SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))'; + END IF; + + RETURN '1 = 0'; +EXCEPTION + WHEN OTHERS THEN + RETURN '1 = 0'; +END; +/ + +SHOW ERRORS FUNCTION hmm_carrier_vpd_filter + +PROMPT === 4. Attaching the VPD policy === +BEGIN + BEGIN + DBMS_RLS.DROP_POLICY( + object_schema => 'ADMIN', + object_name => 'HMM_CARRIER_ASSIGNMENTS_V', + policy_name => 'HMM_CARRIER_SCOPE_POLICY' + ); + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -28102 THEN + RAISE; + END IF; + END; + + DBMS_RLS.ADD_POLICY( + object_schema => 'ADMIN', + object_name => 'HMM_CARRIER_ASSIGNMENTS_V', + policy_name => 'HMM_CARRIER_SCOPE_POLICY', + function_schema => 'ADMIN', + policy_function => 'HMM_CARRIER_VPD_FILTER', + statement_types => 'SELECT', + update_check => FALSE, + enable => TRUE, + policy_type => DBMS_RLS.CONTEXT_SENSITIVE + ); +END; +/ + +PROMPT === 5. Granting the non-exempt runtime read boundary === +GRANT SELECT ON hmm_carrier_assignments_v TO cb_ords; +GRANT SELECT ON hmm_rds_carriers_v TO cb_ords; +GRANT SELECT ON hmm_rds_carrier_perf_v TO cb_ords; +GRANT SELECT ON hmm_rds_carrier_latest_v TO cb_ords; + +CREATE OR REPLACE SYNONYM cb_ords.hmm_carrier_assignments_v + FOR admin.hmm_carrier_assignments_v; +CREATE OR REPLACE SYNONYM cb_ords.hmm_rds_carriers_v + FOR admin.hmm_rds_carriers_v; +CREATE OR REPLACE SYNONYM cb_ords.hmm_rds_carrier_perf_v + FOR admin.hmm_rds_carrier_perf_v; +CREATE OR REPLACE SYNONYM cb_ords.hmm_rds_carrier_latest_v + FOR admin.hmm_rds_carrier_latest_v; + +PROMPT === 6. Recording policy notes === +MERGE INTO hmm_access_vpd_filter_notes dst +USING ( + SELECT 'ADMIN' function_owner, + 'HMM_CARRIER_VPD_FILTER' function_name, + 'Filters carrier assignments by the authenticated employee role and employee hierarchy.' description + FROM dual +) src +ON (dst.function_owner = src.function_owner AND dst.function_name = src.function_name) +WHEN MATCHED THEN UPDATE SET + dst.description = src.description, + dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + function_owner, function_name, description, updated_at +) VALUES ( + src.function_owner, src.function_name, src.description, SYSTIMESTAMP +); + +MERGE INTO hmm_access_vpd_policy_notes dst +USING ( + SELECT 'ADMIN' object_owner, + 'HMM_CARRIER_ASSIGNMENTS_V' object_name, + 'HMM_CARRIER_SCOPE_POLICY' policy_name, + 'Restricts carrier assignments to self, direct reports, or explicit HR administrator access.' description + FROM dual +) src +ON ( + dst.object_owner = src.object_owner + AND dst.object_name = src.object_name + AND dst.policy_name = src.policy_name +) +WHEN MATCHED THEN UPDATE SET + dst.description = src.description, + dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + object_owner, object_name, policy_name, description, updated_at +) VALUES ( + src.object_owner, src.object_name, src.policy_name, + src.description, SYSTIMESTAMP +); + +COMMIT; + +PROMPT === 7. Verification inventory === +SELECT object_name, policy_name, function, sel, enable, policy_type +FROM all_policies +WHERE object_owner = 'ADMIN' + AND object_name = 'HMM_CARRIER_ASSIGNMENTS_V' + AND policy_name = 'HMM_CARRIER_SCOPE_POLICY'; + +SELECT role.role_name, + permission.target_name, + rule.rule_column, + rule.rule_type +FROM hmm_access_permissions permission +JOIN hmm_access_roles role ON role.role_id = permission.role_id +JOIN hmm_access_permission_rules rule ON rule.perm_id = permission.perm_id +WHERE permission.target_name = 'HMM_CARRIER_ASSIGNMENTS_V' +ORDER BY role.role_name; + +PROMPT === HMM carrier team VPD ready === diff --git a/docs/design/740-hmm-mcp-vpd-runtime/README.md b/docs/design/740-hmm-mcp-vpd-runtime/README.md index c26c705..4a35c87 100644 --- a/docs/design/740-hmm-mcp-vpd-runtime/README.md +++ b/docs/design/740-hmm-mcp-vpd-runtime/README.md @@ -1,123 +1,52 @@ -# #740 HMM MCP VPD 실행 경계 복구 +# #740 HMM MCP VPD 실행 경계 -## 배경 +## 목적 -HMM MCP의 `search_hr_data`는 현재 `ADMIN` JDBC 세션에서 -`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 실행한다. 토큰으로 -`HMM_ACCESS_CTX`를 설정해도 `ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 -`ADMIN.HMM_LEAVE_BALANCES`와 `ADMIN.HMM_LEAVE_REQUESTS`의 VPD 정책이 -실제 조회에 적용되지 않는다. +HMM MCP의 인증 사용자를 Oracle DB 세션 사용자 문맥으로 연결하고, HR 및 선사 배정 데이터를 +Oracle VPD로 제한한다. 팀원은 본인 행, 팀장은 본인과 직속 팀원 행을 조회한다. -컨텍스트 값이 올바른 것과 VPD가 적용되는 것은 별개의 조건이다. 보호 테이블을 -읽는 최종 SQL은 반드시 `EXEMPT ACCESS POLICY`가 없는 계정의 동일 DB 세션에서 -컨텍스트 설정과 함께 실행해야 한다. +## 범위와 결정사항 -## 목표 +- 포털의 사용자 preset은 각각 다른 Bearer token 환경변수를 사용한다. +- 백오피스는 token hash로 직원을 식별하고 실제 SELECT와 같은 `CB_ORDS` 연결에 + `SET_VPD_CONTEXT`를 호출한다. +- `ADMIN`은 `EXEMPT ACCESS POLICY`가 있으므로 Select AI `SHOWSQL` 생성만 담당한다. +- `HMM_CARRIER_ASSIGNMENTS_V.EMPLOYEE_ID`에 `SELF`, `MANAGED_TEAM`, `ALL` 규칙을 적용한다. +- 기존 MCP 이름 `search_carrier_performance`, 자연어 Select AI, 선사 `CARRIER_CODE` 조인과 HTML + renderer는 유지한다. 고정 SQL이나 대체 조회 패키지를 만들지 않는다. -- `ADMIN`은 Select AI `SHOWSQL` 생성만 담당한다. -- 비면제 런타임 스키마 `CB_ORDS`가 생성 SQL을 읽기 전용으로 실행한다. -- 실행 직전 같은 `CB_ORDS` 세션에서 - `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다. -- 실행 종료 시 성공·실패와 관계없이 `CLEAR_VPD_CONTEXT`를 호출하고 롤백한다. -- 런타임 계정이 `ADMIN`이거나 `EXEMPT ACCESS POLICY`를 가진 경우 fail-closed 한다. -- MCP `search_hr_data`만 새 경계로 전환하며 용어·정책 검색 도구는 기존 경로를 유지한다. - -## 구조 +## 전체 구성 ```text -MCP tools/call - └─ bearer token 해시 인증 - ├─ ADMIN / DBMS_CLOUD_AI.GENERATE(..., 'showsql') - │ └─ 허용된 HMM HR 객체만 포함한 SELECT/WITH 생성 - └─ CB_ORDS JDBC session - ├─ CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ...') - │ └─ ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(token) - ├─ runtime principal/컨텍스트 일치 검증 - ├─ SET TRANSACTION READ ONLY - ├─ 생성 SQL 실행 → HMM_LEAVE_SCOPE_POLICY 적용 - ├─ ROLLBACK - └─ CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT +포털 사용자 preset + → 사용자별 Bearer token + → token hash로 EMPLOYEE_ID 확인 + → ADMIN Select AI SHOWSQL 생성 + → CB_ORDS 연결에서 SET_VPD_CONTEXT + → 생성 SELECT 실행 + → HMM_CARRIER_ASSIGNMENTS_V VPD + ├─ E1001 팀장: 직속 팀원 배정 8건 + └─ E1002 팀원: 본인 배정 C001·C002 2건 + → 허용된 CARRIER_CODE만 원격 KPI와 조인 + → HTML renderer ``` -## DB 설계 +핵심은 token 소유자, DB context의 직원, VPD predicate가 동일한 요청 안에서 이어지는 것이다. +`SET_VPD_CONTEXT`를 호출해도 `EXEMPT ACCESS POLICY`를 가진 연결에서 SELECT하면 정책이 우회된다. -### 런타임 스키마 +## 현재 상태 -`CB_ORDS`에는 다음 최소 권한만 부여한다. +2026-08-10 운영 적용 및 MCP 호출 검증을 완료했다. -- `CREATE SESSION` -- `ADMIN.CB_ORDS_HANDLER_PKG` 실행 -- Select AI 프로필의 승인 객체 6개에 대한 `SELECT` - - `HMM_ORG_TEAMS` - - `HMM_HR_EMPLOYEES` - - `HMM_LEAVE_BALANCES` - - `HMM_LEAVE_REQUESTS` - - `HMM_ATTENDANCE_DAILY` - - `HMM_HR_TERMS` +- 사용자별 portal token 5개가 서로 다른 hash로 직원에게 연결됨 +- `HMM_CARRIER_SCOPE_POLICY` 활성화 +- MCP E1001: `vpdEnforced=true`, 8건 +- MCP E1002: `vpdEnforced=true`, 2건, 직원 범위 E1002만 포함 +- E1002 renderer: 입력 2건, HTML에 C001·C002 포함, E1003 미포함 -`EXEMPT ACCESS POLICY`, `SELECT ANY TABLE`, 객체 생성 `ANY` 권한은 부여하지 않는다. -생성 SQL의 스키마 한정 여부에 영향을 받지 않도록 승인 객체에 한해 -`CB_ORDS` private synonym을 만든다. +## 문서 지도 -### 컨텍스트 패키지 - -`ADMIN.CB_ORDS_HANDLER_PKG`는 HTTP Authorization 값에서 Bearer 토큰을 -추출한 후 `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 호출한다. -토큰 원문은 테이블이나 로그에 저장하지 않는다. - -`CB_ORDS`에는 동일 이름의 private synonym만 제공하여 런타임 SQL에서는 -`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`로 호출한다. 컨텍스트 패키지 자체의 -직접 실행 권한은 런타임 계정에 노출하지 않는다. - -`CLEAR_VPD_CONTEXT`는 HMM 컨텍스트와 client identifier를 모두 정리한다. -유효하지 않은 인증 헤더는 컨텍스트를 먼저 지운 뒤 오류로 종료한다. - -## 애플리케이션 설계 - -`backoffice.select-ai` 설정을 두 연결로 분리한다. - -- 생성 연결: 기존 `db-url`, `db-username`, `db-password`, `profile` -- 실행 연결: `runtime-db-url`, `runtime-db-username`, `runtime-db-password` - -생성 연결은 기존 `BACKOFFICE_DB_*`를 기본값으로 사용할 수 있다. 실행 연결은 -명시적으로 설정해야 하며 생성 연결로 자동 폴백하지 않는다. - -실행 전 다음을 검증한다. - -1. `USER`가 `ADMIN`이 아니다. -2. `SESSION_PRIVS`에 `EXEMPT ACCESS POLICY`가 없다. -3. 패키지 호출 후 `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치한다. - -하나라도 실패하면 SQL을 실행하지 않는다. - -## 운영 설정 - -`search_hr_data`의 `executionType`을 `AGENT_TOOL`에서 `SELECT_AI`로 변경한다. -다른 두 도구는 그대로 유지한다. - -필수 환경값: - -```properties -BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE -BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= -BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS -BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD= -``` - -비밀번호는 Git·Redmine·로그에 기록하지 않고 서버 환경 파일에서만 관리한다. - -## 검증 기준 - -1. `CB_ORDS`에 `EXEMPT ACCESS POLICY`가 없음을 확인한다. -2. E1006 토큰으로 `HMM_LEAVE_BALANCES` 조회 결과의 직원은 E1006 한 명뿐이다. -3. E1006 토큰으로 `HMM_LEAVE_REQUESTS` 조회 결과에 E1002가 없다. -4. E1006이 E1002 휴가를 직접 요청하면 0건을 반환한다. -5. 관리자 E1001은 권한 규칙에 따라 자기 자신과 직속 팀원 범위를 조회한다. -6. 무효 토큰, 컨텍스트 불일치, 런타임 권한 오설정은 fail-closed 한다. - -## 롤백 - -- 애플리케이션 환경의 `search_hr_data`를 이전 `AGENT_TOOL` 정의로 되돌리고 - 이전 JAR를 재기동한다. -- `CB_ORDS` 스키마는 즉시 삭제하지 않고 계정을 잠가 조사 가능 상태로 보존한다. -- VPD 정책 자체와 기존 `HMM_ACCESS_CTX_PKG`는 변경하지 않는다. +- [아키텍처와 신뢰 경계](architecture.md) +- [적용 및 검증 절차](cookbook.md) +- [문제 해결](troubleshooting.md) +- [HTML 리포트 후속 처리](../hmm-html-report-mcp/README.md) diff --git a/docs/design/740-hmm-mcp-vpd-runtime/architecture.md b/docs/design/740-hmm-mcp-vpd-runtime/architecture.md new file mode 100644 index 0000000..e3875de --- /dev/null +++ b/docs/design/740-hmm-mcp-vpd-runtime/architecture.md @@ -0,0 +1,41 @@ +# HMM MCP VPD 아키텍처 + +[개요](README.md) · [적용 절차](cookbook.md) · [문제 해결](troubleshooting.md) + +## 권한 흐름 + +```text +포털 사용자 preset + → 사용자별 Bearer token + → token hash로 EMPLOYEE_ID 식별 + → HMM_ACCESS_CTX.EMPLOYEE_ID 설정 + → Select AI가 만든 SELECT를 CB_ORDS에서 실행 + → HMM_CARRIER_ASSIGNMENTS_V VPD + ├─ VIEWER: 본인 EMPLOYEE_ID + └─ MANAGER: 본인과 직속 팀원 EMPLOYEE_ID + → 허용된 CARRIER_CODE만 원격 선사 실적과 조인 + → HTML renderer +``` + +선사 조회의 자연어 처리와 `CARRIER_CODE` 조인은 기존 Select AI 프로필을 유지한다. 권한 조건을 +질문이나 고정 SQL에 넣지 않고 Oracle VPD가 세션 사용자 ID로 자동 적용한다. + +## 신뢰 경계 + +- 화면에 표시된 사용자 코드는 권한 근거가 아니다. 선택 preset의 전용 Bearer token이 근거다. +- 토큰 원문은 서버 환경에만 저장하고 DB에는 SHA-256 hash만 저장한다. +- `ADMIN`은 `EXEMPT ACCESS POLICY`가 있으므로 SQL 생성만 수행한다. +- 실제 SELECT는 비면제 계정 `CB_ORDS`가 같은 요청의 VPD context를 설정한 뒤 실행한다. +- renderer는 이미 필터링된 행만 표현하며 사용자나 권한을 다시 판단하지 않는다. + +## 선사 VPD 규칙 + +보호 객체는 `ADMIN.HMM_CARRIER_ASSIGNMENTS_V`, 기준 컬럼은 `EMPLOYEE_ID`다. + +| 역할 | 규칙 | 적용 범위 | +|---|---|---| +| `HMM_HR_VIEWER` | `SELF` | 현재 인증 사용자 | +| `HMM_HR_MANAGER` | `MANAGED_TEAM` | 현재 사용자와 직속 팀원 | +| `HMM_HR_ADMIN` | `ALL` | 명시적 관리자 전체 | + +컨텍스트, 활성 역할 또는 허용 규칙이 없으면 `1=0`으로 차단한다. diff --git a/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md b/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md new file mode 100644 index 0000000..442df96 --- /dev/null +++ b/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md @@ -0,0 +1,25 @@ +# HMM MCP VPD 적용 절차 + +[개요](README.md) · [아키텍처](architecture.md) · [문제 해결](troubleshooting.md) + +1. `ADMIN`이 VPD 우회 권한을 가지고 `CB_ORDS`는 가지지 않는지 확인한다. +2. `database/adb/84_hmm_carrier_team_vpd.sql`을 ADMIN으로 실행한다. +3. 포털 preset마다 `HMM_MCP_BEARER_TOKEN_` 전용 토큰을 발급한다. +4. 선사 조회 MCP는 기존 `HMM_RDS_FEDERATION_PROFILE`로 `SHOWSQL`을 생성하고 `CB_ORDS`에서 실행한다. + 운영 도구 정의는 이름과 입력 계약을 유지하고 `executionType`만 `SELECT_AI`로 설정한다. +5. 서비스를 재시작하고 동일 질문을 E1001과 E1002로 각각 호출한다. + +성공 기준: + +| 사용자 | 허용된 선사 배정 | 예상 건수 | +|---|---|---:| +| E1001 팀장 | 직속 팀원 C001~C008 | 8 | +| E1002 팀원 | 본인 C001, C002 | 2 | + +첫 조회 결과, renderer 입력, HTML 표의 행 수와 직원 범위가 모두 같아야 한다. 토큰 원문과 DB +비밀번호는 명령·로그·문서에 출력하지 않는다. + +MCP 응답에서 `vpdEnforced=true`, `scopeEmployeeCode`가 선택 사용자와 같은지도 확인한다. + +롤백할 때는 VPD 정책을 삭제하지 말고 disable하여 조사 가능 상태로 보존한다. 사용자별 token +환경변수도 공용 token으로 되돌리지 않고 사용자 선택 기능을 일시 비활성화한다. diff --git a/docs/design/740-hmm-mcp-vpd-runtime/troubleshooting.md b/docs/design/740-hmm-mcp-vpd-runtime/troubleshooting.md new file mode 100644 index 0000000..7cbe32f --- /dev/null +++ b/docs/design/740-hmm-mcp-vpd-runtime/troubleshooting.md @@ -0,0 +1,27 @@ +# HMM MCP VPD 문제 해결 + +[개요](README.md) · [아키텍처](architecture.md) · [적용 절차](cookbook.md) + +## 사용자를 바꿔도 같은 결과 + +- 원인: preset들이 같은 token 환경변수를 참조하거나 선사 View에 VPD가 없다. +- 확인: token 원문을 출력하지 않고 preset 환경변수 이름과 token hash의 employee mapping을 확인한다. +- 해결: 사용자별 token을 발급하고 `HMM_CARRIER_SCOPE_POLICY`를 활성화한다. + +## 컨텍스트는 다른데 전체 행이 보임 + +- 원인: 최종 SELECT가 `EXEMPT ACCESS POLICY`를 가진 ADMIN에서 실행됐다. +- 확인: 실행 계정의 `SESSION_PRIVS`와 MCP 응답의 `scopeEmployeeCode`를 확인한다. +- 해결: ADMIN은 Select AI `SHOWSQL`만 수행하고 실제 SELECT는 `CB_ORDS`에서 실행한다. + +## 팀원 결과가 0건 + +- 원인: token→직원, 직원→역할, 직원→선사 access group 중 하나가 누락됐다. +- 확인: 위 순서로 원장 연결을 확인한다. +- 해결: 누락된 원장만 보강한다. 조회 함수에 직원 코드나 결과 행을 하드코딩하지 않는다. + +## 원격 선사 KPI가 과다 노출됨 + +- 원인: 생성 SQL이 로컬 `HMM_CARRIER_ASSIGNMENTS_V` 없이 원격 KPI View만 읽었다. +- 해결: Select AI profile metadata에서 직원·팀 질의는 배정 View와 KPI View를 `CARRIER_CODE`로 + 조인하도록 유지한다. SQL 자체는 고정하지 않는다. diff --git a/docs/design/hmm-html-report-mcp/troubleshooting.md b/docs/design/hmm-html-report-mcp/troubleshooting.md index ec3713c..ab69742 100644 --- a/docs/design/hmm-html-report-mcp/troubleshooting.md +++ b/docs/design/hmm-html-report-mcp/troubleshooting.md @@ -91,3 +91,16 @@ JSON 배열 문자열을 재귀적으로 처리하는지 확인한다. 조회 To **재발 방지**: 중첩 JSON 배열 회귀 테스트와 운영 연속 호출 검증을 유지한다. 같은 Bearer 문맥에서 조회 원본 행 수와 renderer 입력 행 수가 같아야 배포를 완료한다. + +## 첫 리포트는 정상인데 두 번째부터 값이 비거나 깨짐 + +**원인**: Select AI가 컬럼 label을 underscore 대신 공백 또는 혼합 대소문자로 반환했고, 포털이 +underscore만 camelCase로 변환했다. + +**확인**: 연속 호출의 `reportJson.rows`에서 `LATEST_REVENUE_USD`, `LATEST REVENUE USD`, +`latest Revenue Usd`가 모두 `latestRevenueUsd`로 정규화되는지 확인한다. + +**해결**: 공백, underscore, hyphen을 공통 단어 경계로 처리한다. 대화 상태나 업무 컬럼 이름을 +조건문에 하드코딩하지 않는다. + +**재발 방지**: 서로 다른 컬럼 label 표기의 두 응답을 연속 정규화하는 회귀 테스트를 유지한다.