refs #740: enforce carrier VPD by MCP user
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
권한 문맥은 바뀌지 않으므로 허용하지 않는다.
|
||||
|
||||
## 검증
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
323
database/adb/84_hmm_carrier_team_vpd.sql
Normal file
323
database/adb/84_hmm_carrier_team_vpd.sql
Normal 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 ===
|
||||
@@ -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)
|
||||
|
||||
41
docs/design/740-hmm-mcp-vpd-runtime/architecture.md
Normal file
41
docs/design/740-hmm-mcp-vpd-runtime/architecture.md
Normal 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`으로 차단한다.
|
||||
25
docs/design/740-hmm-mcp-vpd-runtime/cookbook.md
Normal file
25
docs/design/740-hmm-mcp-vpd-runtime/cookbook.md
Normal 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으로 되돌리지 않고 사용자 선택 기능을 일시 비활성화한다.
|
||||
27
docs/design/740-hmm-mcp-vpd-runtime/troubleshooting.md
Normal file
27
docs/design/740-hmm-mcp-vpd-runtime/troubleshooting.md
Normal 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 자체는 고정하지 않는다.
|
||||
@@ -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 표기의 두 응답을 연속 정규화하는 회귀 테스트를 유지한다.
|
||||
|
||||
Reference in New Issue
Block a user