[Developer] #619 map VPD rules to token context variables

This commit is contained in:
devmrko
2026-07-07 14:24:50 +09:00
parent 2cdc738eb9
commit ef944a6758
9 changed files with 262 additions and 80 deletions

View File

@@ -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` 상태로 확인됐다.

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ public class PermissionService {
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG",
"STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
"=", "!=", "DEPT", "EMP_NO", "TAG");
"=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(
"MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
<h1>접근 규칙</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제에서 <code>STAKEHOLDER_SELF</code>는 토큰의 이해당사자 ID, <code>STAKEHOLDER_CHANNEL</code>역할에 연결된 채널로 행 범위를 계산합니다.</p>
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제는 토큰 주체에서 한 번 해석한 이해당사자 ID·역할·채널을 secure context로 사용하고, 대상 고객은 계약원장으로만 연결합니다. <code>STAKEHOLDER_SELF</code> <code>STAKEHOLDER_CHANNEL</code>각각의 역할 분기로 만들고 허용 분기끼리는 OR로 계산합니다.</p>
</details>
</div>
@@ -178,7 +178,7 @@
</div>
<details class="explanation-details">
<summary>행 규칙과 기술 태그 OR 조건 보기</summary>
<p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. <code>이해당사자 본인 담당</code><code>FC_ID</code>를 직접 비교하거나 <code>CUST_ID</code>/<code>CONTRACT_NO</code>를 계약원장에 안전하게 연결합니다. <code>이해당사자 채널 범위</code>도 같은 방식으로 <code>FC_CHANNEL</code>을 비교합니다. 두 이해당사자 규칙은 비교할 컬럼을 반드시 선택하세요.</p>
<p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. 이해당사자 규칙은 <strong>비교 컬럼</strong><strong>업무 역할값</strong>(예: 설계사, 지점장)을 모두 입력하세요. 토큰을 해석한 뒤 <code>STAKEHOLDER_USER_ID</code>·<code>STAKEHOLDER_ROLE</code>·<code>STAKEHOLDER_CHANNEL</code> context를 사용합니다. <code>STAKEHOLDER_SELF</code><code>c.FC_ID = context 사용자</code>, <code>STAKEHOLDER_CHANNEL</code> <code>c.FC_CHANNEL = context 채널</code>이며 서로 다른 허용 규칙은 OR로 결합됩니다.</p>
</details>
</div>
</div>
@@ -241,6 +241,7 @@
<section class="content-band">
<h2>접근 규칙 목록</h2>
<p class="section-subtitle">같은 보호 대상에 적용되는 ALLOW 역할 분기와 한 권한 안의 여러 행 규칙은 OR로 합칩니다. 예를 들어 설계사 본인계약 분기 또는 지점장 채널 분기 중 하나라도 성립하면 행이 허용됩니다. DENY 분기는 허용 결과에서 별도로 제외됩니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle permission-list-table">
<thead>

View File

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