refs #740: enforce carrier VPD by MCP user

This commit is contained in:
devmrko
2026-08-10 17:26:01 +09:00
parent 2f5fc2bfbd
commit 6394bbf078
11 changed files with 521 additions and 122 deletions

View File

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

View File

@@ -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
권한 문맥은 바뀌지 않으므로 허용하지 않는다.
## 검증

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=<ADB JDBC URL>
BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS
BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<secret>
```
비밀번호는 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)

View File

@@ -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`으로 차단한다.

View File

@@ -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_<EMPLOYEE_CODE>` 전용 토큰을 발급한다.
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으로 되돌리지 않고 사용자 선택 기능을 일시 비활성화한다.

View File

@@ -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 자체는 고정하지 않는다.

View File

@@ -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 표기의 두 응답을 연속 정규화하는 회귀 테스트를 유지한다.