diff --git a/docs/design/619-stakeholder-token-permission-example/README.md b/docs/design/619-stakeholder-token-permission-example/README.md index 16c9c02..42cd5fb 100644 --- a/docs/design/619-stakeholder-token-permission-example/README.md +++ b/docs/design/619-stakeholder-token-permission-example/README.md @@ -13,7 +13,7 @@ - `CB_AGENT_BEARER_KEY.STAKEHOLDER_USER_ID`가 `KB_STAKEHOLDERS.USER_ID`를 가리킨다. 기존 숫자형 `USER_ID`는 기존 기능 호환용 application-user bridge로 유지한다. - 이해당사자마다 `CB_APP_USER.STAKEHOLDER_USER_ID` bridge를 하나 만들고, `KB_STAKEHOLDERS.ROLE`이 `지점장`이면 `KB_BRANCH_MANAGER_ROLE`, `설계사`이면 `KB_DESIGNER_ROLE`을 부여한다. 다른 역할은 토큰은 발급되지만 별도 권한을 추가하기 전까지 fail-closed다. - VPD package는 토큰에서 `STAKEHOLDER_USER_ID`, `STAKEHOLDER_ROLE`, 정규화된 `STAKEHOLDER_CHANNEL`을 application context에 설정한다. `설계사채널 → 설계사`, `GA채널 → GA`를 정규화한다. -- `지점장` 여부는 package의 하드코딩 분기가 아니라 `KB_BRANCH_MANAGER_ROLE`에 부여된 접근 규칙으로 결정한다. +- `지점장` 여부는 package의 하드코딩 분기가 아니라 `KB_BRANCH_MANAGER_ROLE`의 rule value(`지점장`)와 `KB_STAKEHOLDERS.ROLE` 일치로 결정한다. 설계사 분기는 같은 방식으로 `설계사`를 확인한다. - `RLS_FILTER`의 문자열은 실행하지 않는다. 권한 테이블에 저장된 allowlist rule type만 predicate로 변환한다. ## 3. 예제 권한 @@ -26,7 +26,27 @@ | KB_BRANCH_MANAGER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / STAKEHOLDER_CHANNEL` | 같은 채널 고객 기준 | | 두 역할 | KB_STAKEHOLDERS | `USER_ID / STAKEHOLDER_SELF` | 토큰 주체 자신의 매핑 행 | -`STAKEHOLDER_SELF`와 `STAKEHOLDER_CHANNEL`은 접근 규칙 화면에서 선택 가능한 행 규칙이다. `FC_ID`/`FC_CHANNEL`에서는 직접 비교하고, `CUST_ID`/`CONTRACT_NO`에서는 계약원장과 안전한 EXISTS 조인으로 변환한다. +`STAKEHOLDER_SELF`와 `STAKEHOLDER_CHANNEL`은 접근 규칙 화면에서 선택 가능한 행 규칙이다. 두 규칙에는 대상 컬럼과 업무 역할값을 함께 저장한다. Bearer Token을 해석할 때 `KB_STAKEHOLDERS`를 한 번만 조회해 `STAKEHOLDER_USER_ID`, `STAKEHOLDER_ROLE`, `STAKEHOLDER_CHANNEL` secure context를 만들고, 이후 `FC_ID`/`FC_CHANNEL`과 `CUST_ID`/`CONTRACT_NO` 조건은 계약원장과 그 context만 사용한다. 허용된 역할별 분기는 **OR**로 합쳐진다. + +```sql +-- 고객·청구·외부보유의 CUST_ID 예시: 역할별 허용 분기 +EXISTS ( + SELECT 1 + FROM POC_2.KB_CONTRACTS c + WHERE c.CUST_ID = <현재행>.CUST_ID + AND SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE') = '설계사' + AND c.FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID') +) +OR EXISTS ( + SELECT 1 + FROM POC_2.KB_CONTRACTS c + WHERE c.CUST_ID = <현재행>.CUST_ID + AND SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE') = '지점장' + AND c.FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL') +) +``` + +위의 `'설계사'`/`'지점장'`은 함수 하드코딩이 아니라 각 permission rule의 `rule_value`에서 온다. 토큰 원문은 predicate에 노출하지 않으며, `CB_AGENT_BEARER_KEY → KB_STAKEHOLDERS`에서 한 번 검증·해석한 subject context만 사용한다. ## 4. CLS / NULL 처리 @@ -75,7 +95,7 @@ Bearer Token → CB_AGENT_CTX_PKG → application context ### 2026-07-07 배포 검증 결과 -- Maven 테스트 77건 통과. +- Maven 테스트 78건 통과. - HTTPS 인증 요청으로 `/permissions`, `/tokens`가 각각 200을 반환했다. 기존 `/permissions`의 잘못된 Thymeleaf fallback 표현식도 함께 수정했다. - `CB_APP_USER` bridge 13명, 지점장 역할 2명, 설계사 역할 5명이 동기화됐다. - `POC_2`에 행 VPD 7개와 민감 컬럼 NULL VPD policy 7개, 관련 CLS 함수 7개가 모두 `VALID` 상태로 확인됐다. diff --git a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql index 73fda73..238f340 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -170,56 +170,79 @@ BEGIN ); END IF; ELSIF r.rule_type IN ('STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL') THEN - -- 업무 사용자 원장(KB_STAKEHOLDERS)에서 해석한 토큰 주체를 쓴다. - -- CUST_ID/CONTRACT_NO는 계약원장을 한 단계 감싼 EXISTS로만 연결해 - -- 임의 SQL이나 외부 rule text가 predicate에 섞이지 않게 한다. + -- CB_AGENT_CTX_PKG가 Bearer token -> KB_STAKEHOLDERS를 한 번 + -- 해석해 USER_ID/ROLE/CHANNEL을 secure context로 설정한다. + -- 따라서 보호 테이블 predicate에서 KB_STAKEHOLDERS를 다시 조인하지 + -- 않고, 해당 context와 KB_CONTRACTS 관계만 사용한다. v_column := safe_column(p_schema, p_object, r.rule_column); IF v_column IS NOT NULL AND UPPER(p_schema) = 'POC_2' THEN - IF r.rule_type = 'STAKEHOLDER_SELF' - AND ((v_target = 'KB_CONTRACTS' AND v_column = 'FC_ID') - OR (v_target = 'KB_STAKEHOLDERS' AND v_column = 'USER_ID')) THEN - append_or( - r.permission_effect, - v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')' - ); - ELSIF r.rule_type = 'STAKEHOLDER_CHANNEL' - AND v_target = 'KB_CONTRACTS' - AND v_column = 'FC_CHANNEL' THEN - append_or( - r.permission_effect, - v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')' - ); - ELSIF v_target IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS') - AND v_column IN ('CUST_ID', 'CONTRACT_NO') THEN - DECLARE - v_contract_key VARCHAR2(30); - v_context_key VARCHAR2(30); - v_context_name VARCHAR2(30); - BEGIN + DECLARE + v_contract_key VARCHAR2(30); + v_role_condition VARCHAR2(500); + BEGIN + -- 각 ALLOW rule은 append_or로 OR 분기가 된다. rule_value는 + -- 권한 설정에 저장된 해당 분기의 역할값이다. identity rule은 + -- NULL value를 허용해 모든 토큰 주체가 자기 mapping을 해석한다. + v_role_condition := CASE + WHEN TRIM(r.rule_value) IS NULL THEN '1 = 1' + ELSE 'SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ' + || quote_literal(TRIM(r.rule_value)) + END; + + IF r.rule_type = 'STAKEHOLDER_SELF' + AND v_target = 'KB_CONTRACTS' + AND v_column = 'FC_ID' THEN + append_or( + r.permission_effect, + '(' || v_role_condition || + ' AND FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))' + ); + ELSIF r.rule_type = 'STAKEHOLDER_CHANNEL' + AND v_target = 'KB_CONTRACTS' + AND v_column = 'FC_CHANNEL' THEN + append_or( + r.permission_effect, + '(' || v_role_condition || + ' AND FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))' + ); + ELSIF r.rule_type = 'STAKEHOLDER_SELF' + AND v_target = 'KB_STAKEHOLDERS' + AND v_column = 'USER_ID' THEN + append_or( + r.permission_effect, + '(' || v_role_condition || + ' AND USER_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))' + ); + ELSIF v_target IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS') + AND v_column IN ('CUST_ID', 'CONTRACT_NO') THEN v_contract_key := CASE v_column WHEN 'CUST_ID' THEN 'CUST_ID' WHEN 'CONTRACT_NO' THEN 'CONTRACT_NO' END; - v_context_key := CASE r.rule_type - WHEN 'STAKEHOLDER_SELF' THEN 'FC_ID' - ELSE 'FC_CHANNEL' - END; - v_context_name := CASE r.rule_type - WHEN 'STAKEHOLDER_SELF' THEN 'STAKEHOLDER_USER_ID' - ELSE 'STAKEHOLDER_CHANNEL' - END; - append_or( - r.permission_effect, - 'EXISTS (SELECT 1 FROM (' || - 'SELECT c.' || v_contract_key || ' AS access_key ' || - 'FROM POC_2.KB_CONTRACTS c ' || - 'WHERE c.' || v_context_key || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''' || - v_context_name || ''')) allowed_contract ' || - 'WHERE allowed_contract.access_key = ' || v_column || ')' - ); - END; - END IF; + IF r.rule_type = 'STAKEHOLDER_SELF' THEN + append_or( + r.permission_effect, + 'EXISTS (SELECT 1 FROM (' || + 'SELECT c.' || v_contract_key || ' AS access_key ' || + 'FROM POC_2.KB_CONTRACTS c ' || + 'WHERE ' || v_role_condition || + ' AND c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')) allowed_contract ' || + 'WHERE allowed_contract.access_key = ' || v_column || ')' + ); + ELSE + append_or( + r.permission_effect, + 'EXISTS (SELECT 1 FROM (' || + 'SELECT c.' || v_contract_key || ' AS access_key ' || + 'FROM POC_2.KB_CONTRACTS c ' || + 'WHERE ' || v_role_condition || + ' AND c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')) allowed_contract ' || + 'WHERE allowed_contract.access_key = ' || v_column || ')' + ); + END IF; + END IF; + END; END IF; ELSIF r.rule_type = '=' THEN v_column := safe_column(p_schema, p_object, r.rule_column); diff --git a/sql/adb/46_kb_stakeholder_token_permission_example.sql b/sql/adb/46_kb_stakeholder_token_permission_example.sql index 4cfda01..0e3dd10 100644 --- a/sql/adb/46_kb_stakeholder_token_permission_example.sql +++ b/sql/adb/46_kb_stakeholder_token_permission_example.sql @@ -313,7 +313,8 @@ DECLARE p_role_id IN NUMBER, p_target_name IN VARCHAR2, p_rule_column IN VARCHAR2, - p_rule_type IN VARCHAR2 + p_rule_type IN VARCHAR2, + p_rule_value IN VARCHAR2 ) IS v_permission_id NUMBER; BEGIN @@ -328,7 +329,7 @@ DECLARE INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) VALUES (v_permission_id, p_role_id, p_target_name, 'SELECT', 'ALLOW'); INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) - VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, p_rule_column, p_rule_type, NULL); + VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, p_rule_column, p_rule_type, p_rule_value); END; BEGIN ensure_role('KB_DESIGNER_ROLE', 'CONFIDENTIAL', v_designer_role_id); @@ -382,20 +383,20 @@ BEGIN -- 토큰은 모든 이해당사자에게 발급되지만, 아래 역할이 없는 주체는 -- 권한 레코드가 없으므로 VPD가 1=0으로 fail-closed 된다. - ensure_permission(v_designer_role_id, 'KB_STAKEHOLDERS', 'USER_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_manager_role_id, 'KB_STAKEHOLDERS', 'USER_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_designer_role_id, 'KB_PRODUCTS', NULL, 'ALL'); - ensure_permission(v_manager_role_id, 'KB_PRODUCTS', NULL, 'ALL'); - ensure_permission(v_designer_role_id, 'KB_CONTRACTS', 'FC_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_manager_role_id, 'KB_CONTRACTS', 'FC_CHANNEL', 'STAKEHOLDER_CHANNEL'); - ensure_permission(v_designer_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_manager_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'STAKEHOLDER_CHANNEL'); - ensure_permission(v_designer_role_id, 'KB_COVERAGES', 'CUST_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_manager_role_id, 'KB_COVERAGES', 'CUST_ID', 'STAKEHOLDER_CHANNEL'); - ensure_permission(v_designer_role_id, 'KB_CLAIMS', 'CUST_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_manager_role_id, 'KB_CLAIMS', 'CUST_ID', 'STAKEHOLDER_CHANNEL'); - ensure_permission(v_designer_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'STAKEHOLDER_SELF'); - ensure_permission(v_manager_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'STAKEHOLDER_CHANNEL'); + ensure_permission(v_designer_role_id, 'KB_STAKEHOLDERS', 'USER_ID', 'STAKEHOLDER_SELF', '설계사'); + ensure_permission(v_manager_role_id, 'KB_STAKEHOLDERS', 'USER_ID', 'STAKEHOLDER_SELF', '지점장'); + ensure_permission(v_designer_role_id, 'KB_PRODUCTS', NULL, 'ALL', NULL); + ensure_permission(v_manager_role_id, 'KB_PRODUCTS', NULL, 'ALL', NULL); + ensure_permission(v_designer_role_id, 'KB_CONTRACTS', 'FC_ID', 'STAKEHOLDER_SELF', '설계사'); + ensure_permission(v_manager_role_id, 'KB_CONTRACTS', 'FC_CHANNEL', 'STAKEHOLDER_CHANNEL', '지점장'); + ensure_permission(v_designer_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'STAKEHOLDER_SELF', '설계사'); + ensure_permission(v_manager_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'STAKEHOLDER_CHANNEL', '지점장'); + ensure_permission(v_designer_role_id, 'KB_COVERAGES', 'CUST_ID', 'STAKEHOLDER_SELF', '설계사'); + ensure_permission(v_manager_role_id, 'KB_COVERAGES', 'CUST_ID', 'STAKEHOLDER_CHANNEL', '지점장'); + ensure_permission(v_designer_role_id, 'KB_CLAIMS', 'CUST_ID', 'STAKEHOLDER_SELF', '설계사'); + ensure_permission(v_manager_role_id, 'KB_CLAIMS', 'CUST_ID', 'STAKEHOLDER_CHANNEL', '지점장'); + ensure_permission(v_designer_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'STAKEHOLDER_SELF', '설계사'); + ensure_permission(v_manager_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'STAKEHOLDER_CHANNEL', '지점장'); END; / COMMIT; diff --git a/sql/adb/47_kb_stakeholder_permission_or_branches.sql b/sql/adb/47_kb_stakeholder_permission_or_branches.sql new file mode 100644 index 0000000..070b9c3 --- /dev/null +++ b/sql/adb/47_kb_stakeholder_permission_or_branches.sql @@ -0,0 +1,104 @@ +-- ============================================================ +-- 47_kb_stakeholder_permission_or_branches.sql +-- +-- Refines the KB example so the permission table carries the expected +-- stakeholder role and each role branch becomes an OR candidate in the +-- dynamic VPD filter. Execute after 26 and 46 as ADMIN. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === 1. Storing expected stakeholder role in example rules === +UPDATE cb_permission_rule r +SET rule_value = CASE + WHEN EXISTS ( + SELECT 1 + FROM cb_permission p + JOIN cb_app_role ar ON ar.role_id = p.role_id + WHERE p.perm_id = r.perm_id + AND ar.role_name = 'KB_DESIGNER_ROLE' + ) THEN '설계사' + WHEN EXISTS ( + SELECT 1 + FROM cb_permission p + JOIN cb_app_role ar ON ar.role_id = p.role_id + WHERE p.perm_id = r.perm_id + AND ar.role_name = 'KB_BRANCH_MANAGER_ROLE' + ) THEN '지점장' +END +WHERE r.rule_type IN ('STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL') + AND EXISTS ( + SELECT 1 + FROM cb_permission p + JOIN cb_app_role ar ON ar.role_id = p.role_id + WHERE p.perm_id = r.perm_id + AND ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE') + ); + +PROMPT === 2. Adding an identity role so every stakeholder token can resolve itself === +DECLARE + v_role_id NUMBER; + v_permission_id NUMBER; + v_count NUMBER; +BEGIN + BEGIN + SELECT role_id INTO v_role_id + FROM cb_app_role + WHERE role_name = 'KB_STAKEHOLDER_IDENTITY_ROLE'; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT NVL(MAX(role_id), 0) + 1 INTO v_role_id FROM cb_app_role; + INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level) + VALUES (v_role_id, 'KB_STAKEHOLDER_IDENTITY_ROLE', 'INTERNAL'); + END; + + FOR u IN ( + SELECT user_id + FROM cb_app_user + WHERE stakeholder_user_id IS NOT NULL + AND active = 'Y' + ) LOOP + SELECT COUNT(*) INTO v_count + FROM cb_user_role + WHERE user_id = u.user_id + AND role_id = v_role_id; + IF v_count = 0 THEN + INSERT INTO cb_user_role(user_id, role_id) VALUES (u.user_id, v_role_id); + END IF; + END LOOP; + + BEGIN + SELECT perm_id INTO v_permission_id + FROM cb_permission + WHERE role_id = v_role_id + AND target_name = 'KB_STAKEHOLDERS' + AND action_name = 'SELECT'; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual; + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (v_permission_id, v_role_id, 'KB_STAKEHOLDERS', 'SELECT', 'ALLOW'); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, 'USER_ID', 'STAKEHOLDER_SELF', NULL); + END; +END; +/ +COMMIT; + +PROMPT === 3. Result: role-specific permission branches === +SELECT ar.role_name, + p.target_name, + r.rule_column, + r.rule_type, + NVL(r.rule_value, '(token role)') AS expected_stakeholder_role +FROM cb_permission p +JOIN cb_app_role ar ON ar.role_id = p.role_id +JOIN cb_permission_rule r ON r.perm_id = p.perm_id +WHERE ar.role_name IN ( + 'KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE', 'KB_STAKEHOLDER_IDENTITY_ROLE' +) +ORDER BY ar.role_name, p.target_name, r.rule_id; + +PROMPT === KB role branches are combined as OR by CB_AGENT_DOC_VPD_FILTER === diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index cc0bac2..7de6cf1 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -24,7 +24,7 @@ public class PermissionService { "ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL"); private static final Set VALUE_REQUIRED_RULE_TYPES = Set.of( - "=", "!=", "DEPT", "EMP_NO", "TAG"); + "=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL"); private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of( "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG"); private static final Set PERMISSION_EFFECTS = Set.of("ALLOW", "DENY"); diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index fdc0077..5ccbdcd 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -93,13 +93,27 @@ WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER(' || NVL(pr2.rule_column, 'TECH_TAG') || '), ''(^|,)' || REPLACE(UPPER(pr2.rule_value), '''', '''''') || '(,|$)'')' - WHEN pr2.rule_type = 'STAKEHOLDER_SELF' THEN pr2.rule_column - || ' = token stakeholder / 담당계약' - WHEN pr2.rule_type = 'STAKEHOLDER_CHANNEL' THEN pr2.rule_column - || ' = token stakeholder channel / 담당계약' + WHEN pr2.rule_type = 'STAKEHOLDER_SELF' AND pr2.rule_column = 'FC_ID' THEN + '(SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' ' + || 'AND FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))' + WHEN pr2.rule_type = 'STAKEHOLDER_SELF' AND pr2.rule_column = 'USER_ID' THEN + '(USER_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))' + WHEN pr2.rule_type = 'STAKEHOLDER_CHANNEL' AND pr2.rule_column = 'FC_CHANNEL' THEN + '(SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' ' + || 'AND FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))' + WHEN pr2.rule_type = 'STAKEHOLDER_SELF' THEN + 'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c ' + || 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' ' + || 'AND SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' ' + || 'AND c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))' + WHEN pr2.rule_type = 'STAKEHOLDER_CHANNEL' THEN + 'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c ' + || 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' ' + || 'AND SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' ' + || 'AND c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))' ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value END, - CHR(10) || 'AND ' + CHR(10) || 'OR ' ) WITHIN GROUP (ORDER BY pr2.rule_id) FROM cb_permission_rule pr2 WHERE pr2.perm_id = p.perm_id diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 361435c..25bbc33 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -388,7 +388,7 @@ function syncRuleTypeHints(root = document) { } const type = typeSelect.value; - const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'].includes(type); + const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type); const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG'].includes(type); const placeholderByType = { ALL: '값 불필요', @@ -397,15 +397,15 @@ function syncRuleTypeHints(root = document) { DEPT: '예: HR', EMP_NO: '예: E2001', TAG: '예: SPRING_BOOT', - STAKEHOLDER_SELF: '토큰 이해당사자에서 자동 계산', - STAKEHOLDER_CHANNEL: '토큰 채널에서 자동 계산', + STAKEHOLDER_SELF: '예: 설계사', + STAKEHOLDER_CHANNEL: '예: 지점장', '=': '비교 값', '!=': '비교 값' }; columnSelect.required = !columnOptional; valueInput.required = valueRequired; - valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type); + valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF'].includes(type); valueInput.placeholder = placeholderByType[type] || '비교 값'; if (valueInput.disabled) { valueInput.value = ''; @@ -705,9 +705,12 @@ function collectWizardRules(root) { if (type === 'ALL') { return 'ALL'; } - if (['MY_DEPT', 'SELF', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) { + if (['MY_DEPT', 'SELF'].includes(type)) { return `${displayColumn} ${type}`; } + if (['STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) { + return `${displayColumn} ${type} 역할=${value}`; + } if (type === 'TAG') { return `${displayColumn} TAG ${value.toUpperCase()}`; } @@ -740,10 +743,10 @@ function collectWizardPredicates(root) { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`; } if (type === 'STAKEHOLDER_SELF') { - return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID') 또는 담당계약 EXISTS`; + return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`; } if (type === 'STAKEHOLDER_CHANNEL') { - return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL') 또는 담당계약 EXISTS`; + return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`; } if (type === 'DEPT' || type === 'EMP_NO') { return `${displayColumn} = ${sqlLiteral(value)}`; @@ -869,7 +872,7 @@ function permissionWizardRuleValidationMessage(wizard) { if (values.some((rule) => rule.type === 'ALL') && values.length > 1) { return 'ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다. ALL만 남기거나 ALL을 삭제하세요.'; } - const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG']; + const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL']; const seen = new Set(); for (const [index, rule] of values.entries()) { const position = index + 1; diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index 426f298..7d03046 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -8,7 +8,7 @@

접근 규칙

도움말 -

VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제에서 STAKEHOLDER_SELF는 토큰의 이해당사자 ID, STAKEHOLDER_CHANNEL은 역할에 연결된 채널로 행 범위를 계산합니다.

+

VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제는 토큰 주체에서 한 번 해석한 이해당사자 ID·역할·채널을 secure context로 사용하고, 대상 고객은 계약원장으로만 연결합니다. STAKEHOLDER_SELFSTAKEHOLDER_CHANNEL은 각각의 역할 분기로 만들고 허용 분기끼리는 OR로 계산합니다.

@@ -178,7 +178,7 @@
행 규칙과 기술 태그 OR 조건 보기 -

특정 기술 태그는 기본적으로 TECH_TAG 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. 이해당사자 본인 담당FC_ID를 직접 비교하거나 CUST_ID/CONTRACT_NO를 계약원장에 안전하게 연결합니다. 이해당사자 채널 범위도 같은 방식으로 FC_CHANNEL을 비교합니다. 두 이해당사자 규칙은 비교할 컬럼을 반드시 선택하세요.

+

특정 기술 태그는 기본적으로 TECH_TAG 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. 이해당사자 규칙은 비교 컬럼업무 역할값(예: 설계사, 지점장)을 모두 입력하세요. 토큰을 해석한 뒤 STAKEHOLDER_USER_ID·STAKEHOLDER_ROLE·STAKEHOLDER_CHANNEL context를 사용합니다. STAKEHOLDER_SELFc.FC_ID = context 사용자, STAKEHOLDER_CHANNELc.FC_CHANNEL = context 채널이며 서로 다른 허용 규칙은 OR로 결합됩니다.

@@ -241,6 +241,7 @@

접근 규칙 목록

+

같은 보호 대상에 적용되는 ALLOW 역할 분기와 한 권한 안의 여러 행 규칙은 OR로 합칩니다. 예를 들어 설계사 본인계약 분기 또는 지점장 채널 분기 중 하나라도 성립하면 행이 허용됩니다. DENY 분기는 허용 결과에서 별도로 제외됩니다.

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java index a7bfbdd..77f8e70 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java @@ -175,8 +175,8 @@ class PermissionServiceTest { "SELECT", "ALLOW", List.of( - new RuleCommand("DEPT_CODE", "STAKEHOLDER_SELF", null), - new RuleCommand("OWNER_EMP_NO", "STAKEHOLDER_CHANNEL", null) + new RuleCommand("DEPT_CODE", "STAKEHOLDER_SELF", "설계사"), + new RuleCommand("OWNER_EMP_NO", "STAKEHOLDER_CHANNEL", "지점장") ), List.of() ); @@ -205,6 +205,22 @@ class PermissionServiceTest { .hasMessageContaining("컬럼이 필요"); } + @Test + void rejectsStakeholderContextRuleWithoutExpectedRole() { + var command = new PermissionSetCommand( + 10L, + 1L, + "SELECT", + "ALLOW", + List.of(new RuleCommand("DEPT_CODE", "STAKEHOLDER_SELF", null)), + List.of() + ); + + assertThatThrownBy(() -> permissionService.savePermissionSet(command)) + .isInstanceOf(AppException.class) + .hasMessageContaining("값이 필요"); + } + @Test void acceptsMultipleVisibleColumns() { var command = new PermissionSetCommand(