Compare commits

..

2 Commits

Author SHA1 Message Date
devmrko
6921682bcd [Developer] report NULLified KB columns in MCP results 2026-07-07 17:06:49 +09:00
devmrko
2ef04e6ab2 [Developer] #619 focus POC on KB VPD permissions 2026-07-07 16:02:22 +09:00
19 changed files with 1243 additions and 170 deletions

View File

@@ -13,40 +13,40 @@
- `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`의 rule value(`지점장`)와 `KB_STAKEHOLDERS.ROLE` 일치로 결정한다. 설계사 분기는 같은 방식으로 `설계사`를 확인한다.
- `RLS_FILTER`의 문자열은 실행하지 않는다. 권한 테이블에 저장된 allowlist rule type만 predicate로 변환한다.
- 토큰의 업무 역할은 `KB_STAKEHOLDERS`에서 한 번 해석해 context에 넣고, 그 역할에 매핑된 `CB_PERMISSION`이 어떤 조건 코드를 사용할지 결정한다. filter predicate에 업무 역할명은 직접 저장하지 않는다.
- 행 규칙은 두 가지다. allowlist condition code는 secure context 값으로 치환하고, `STATIC_SQL`은 현재 보호 객체 컬럼만 사용하는 검증된 정적 WHERE 조건식을 그대로 추가한다.
## 3. 예제 권한
| 역할 | 보호 객체 | 행 규칙 | 결과 |
|---|---|---|---|
| KB_DESIGNER_ROLE | KB_CONTRACTS | `FC_ID / STAKEHOLDER_SELF` | 본인 담당 계약 |
| KB_BRANCH_MANAGER_ROLE | KB_CONTRACTS | `FC_CHANNEL / STAKEHOLDER_CHANNEL` | 같은 채널 계약 |
| KB_DESIGNER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / STAKEHOLDER_SELF` | 본인 담당 고객 기준 |
| KB_BRANCH_MANAGER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / STAKEHOLDER_CHANNEL` | 같은 채널 고객 기준 |
| 두 역할 | KB_STAKEHOLDERS | `USER_ID / STAKEHOLDER_SELF` | 토큰 주체 자신의 매핑 행 |
| KB_DESIGNER_ROLE | KB_CONTRACTS | `FC_ID / OWN_CONTRACT` | 본인 담당 계약 |
| KB_BRANCH_MANAGER_ROLE | KB_CONTRACTS | `FC_CHANNEL / CHANNEL_CONTRACT` | 같은 채널 계약 |
| KB_DESIGNER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / OWN_CUSTOMER` | 본인 담당 고객 기준 |
| KB_BRANCH_MANAGER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / CHANNEL_CUSTOMER` | 같은 채널 고객 기준 |
| KB_STAKEHOLDER_IDENTITY_ROLE | KB_STAKEHOLDERS | `USER_ID / TOKEN_SUBJECT` | 토큰 주체 자신의 매핑 행 |
`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**로 합쳐진다.
`OWN_CONTRACT`, `CHANNEL_CONTRACT`, `OWN_CUSTOMER`, `CHANNEL_CUSTOMER`, `TOKEN_SUBJECT`는 접근 규칙 화면에서 선택하는 filter 조건 코드다. Bearer Token을 해석할 때 `KB_STAKEHOLDERS`를 한 번만 조회해 `STAKEHOLDER_USER_ID`, `STAKEHOLDER_ROLE`, `STAKEHOLDER_CHANNEL` secure context를 만들고, VPD filter가 condition code를 대상 object의 WHERE 조각으로 확장한다.
각 접근 규칙 안에서는 치환된 condition code와 `STATIC_SQL`**AND**로 합친다. 예를 들어 `본인 담당 고객 / CUST_ID`에 `CONTRACT_STATUS = '정상'` 정적 조건을 더하면, 담당 고객이면서 상태가 정상인 행만 허용된다. 서로 다른 ALLOW 권한(역할/권한 세트)은 **OR**, DENY 권한은 최종 허용 결과에서 제외한다. `STATIC_SQL`은 세미콜론·주석·서브쿼리·다른 테이블 참조를 차단하고 현재 보호 객체의 컬럼과 allowlist SQL 연산자만 허용한다.
```sql
-- 고객·청구·외부보유의 CUST_ID 예시: 역할별 허용 분기
-- 고객·청구·외부보유의 CUST_ID 예시: permission condition code 확장 결과
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만 사용한다.
어느 분기를 사용할지는 token context에 매핑된 effective role의 permission이 결정한다. 토큰 원문은 predicate에 노출하지 않으며, `CB_AGENT_BEARER_KEY → KB_STAKEHOLDERS`에서 한 번 검증·해석한 subject context만 사용한다.
## 4. CLS / NULL 처리
@@ -85,6 +85,7 @@ Bearer Token → CB_AGENT_CTX_PKG → application context
- [x] 설계사 역할은 `FC_ID = stakeholder user_id`와 담당 고객 범위 규칙으로 등록된다.
- [x] 지정 7개 민감 컬럼은 원문 표시 예외가 없으면 NULL 처리하는 VPD column policy가 등록된다.
- [x] 토큰 없음·역할 없음·지원하지 않는 rule mapping은 VPD filter에서 `1 = 0`으로 fail-closed 된다.
- [x] 조건 코드와 정적 SQL 조건은 같은 접근 규칙 안에서 AND로 결합되며, 정적 SQL은 단일 안전 조건식만 저장할 수 있다.
## 7. 검증 계획
@@ -103,5 +104,5 @@ Bearer Token → CB_AGENT_CTX_PKG → application context
## 8. 리스크와 제한
- `CB_APP_USER`는 업무 원장이 아니라 permission engine용 bridge다. 업무 identity의 원천은 반드시 `KB_STAKEHOLDERS`다.
- `RLS_FILTER`는 사람이 읽는 설명으로만 보존한다. 저장된 SQL을 실행하면 rule injection 위험이 있으므로 사용하지 않는다.
- 정적 SQL은 백오피스 검증과 VPD 함수의 이중 검증을 통과한 단일 조건식만 사용한다. 임의 SQL, 주석, 바인드, 서브쿼리, 다른 테이블 참조는 허용하지 않는다.
- 지점장의 “집계만” 요구는 원시 행 조회를 완전히 차단하는 별도 집계 View/ORDS endpoint가 필요하다. 이번 예제는 채널 행 범위와 민감 컬럼 NULL 처리까지 구현하며, 집계 전용 endpoint는 후속 범위다.

View File

@@ -76,7 +76,7 @@ CREATE TABLE cb_permission_rule (
perm_id NUMBER NOT NULL REFERENCES cb_permission(perm_id),
rule_column VARCHAR2(128),
rule_type VARCHAR2(30) NOT NULL,
rule_value VARCHAR2(100)
rule_value VARCHAR2(1000 CHAR)
);
CREATE TABLE cb_permission_column (

View File

@@ -6,8 +6,9 @@
-- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE
-- -> CB_PERMISSION -> CB_PERMISSION_RULE
-- and builds a row predicate for the object passed by DBMS_RLS.
-- Supported rule types include TAG. TAG matches a token in a comma-delimited
-- TECH_TAG value, allowing the vector search view to remain one row per chunk.
-- A permission's rules are combined with AND. Separate ALLOW permissions are
-- combined with OR and DENY permissions are subtracted at the end. This lets
-- a token-context condition be narrowed by a literal static SQL condition.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
@@ -27,6 +28,10 @@ AS
v_target VARCHAR2(128);
v_allow_predicate VARCHAR2(32767);
v_deny_predicate VARCHAR2(32767);
v_active_permission_id NUMBER;
v_active_permission_effect VARCHAR2(10);
v_permission_predicate VARCHAR2(32767);
v_permission_invalid BOOLEAN := FALSE;
FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
BEGIN
@@ -64,7 +69,79 @@ AS
RETURN NULL;
END;
PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
FUNCTION safe_static_predicate(
p_owner IN VARCHAR2,
p_table_name IN VARCHAR2,
p_predicate IN VARCHAR2
) RETURN VARCHAR2 IS
v_predicate VARCHAR2(32767) := TRIM(p_predicate);
v_lexical VARCHAR2(32767);
v_token VARCHAR2(128);
v_index PLS_INTEGER := 1;
v_count NUMBER;
v_has_column BOOLEAN := FALSE;
BEGIN
-- STATIC_SQL is intentionally an expression only: no statement
-- separator/comment/bind/database-link/qualified object is accepted.
-- Every identifier outside a string literal must be a column of the
-- protected object or an allowlisted SQL operator/function.
IF v_predicate IS NULL
OR LENGTH(v_predicate) > 1000
OR INSTR(v_predicate, ';') > 0
OR INSTR(v_predicate, '--') > 0
OR INSTR(v_predicate, '/*') > 0
OR INSTR(v_predicate, '*/') > 0
OR INSTR(v_predicate, ':') > 0
OR INSTR(v_predicate, '@') > 0
OR INSTR(v_predicate, '||') > 0
OR REGEXP_LIKE(v_predicate, '[[:cntrl:]]')
OR MOD(LENGTH(v_predicate) - LENGTH(REPLACE(v_predicate, '''', '')), 2) <> 0 THEN
RETURN NULL;
END IF;
-- Replace SQL string literals before extracting identifiers. Doubled
-- quotes in a literal are supported by the expression below.
v_lexical := REGEXP_REPLACE(v_predicate, '''(''''|[^''])*''', ' ');
IF REGEXP_LIKE(v_lexical, '[[:alpha:]_$#][[:alnum:]_$#]*[[:space:]]*\.') THEN
RETURN NULL;
END IF;
LOOP
v_token := UPPER(REGEXP_SUBSTR(
v_lexical, '[[:alpha:]_$#][[:alnum:]_$#]*', 1, v_index
));
EXIT WHEN v_token IS NULL;
v_index := v_index + 1;
IF v_token IN (
'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'LIKE', 'BETWEEN', 'ESCAPE',
'TRUE', 'FALSE', 'UPPER', 'LOWER', 'TRIM', 'NVL', 'COALESCE',
'TO_CHAR', 'TO_DATE', 'REGEXP_LIKE', 'INSTR', 'LENGTH', 'SUBSTR',
'REPLACE', 'CAST', 'AS', 'DATE', 'TIMESTAMP', 'NUMBER', 'VARCHAR2', 'CHAR'
) THEN
NULL;
ELSE
SELECT COUNT(*)
INTO v_count
FROM all_tab_columns
WHERE owner = UPPER(p_owner)
AND table_name = UPPER(p_table_name)
AND column_name = v_token;
IF v_count = 0 THEN
RETURN NULL;
END IF;
v_has_column := TRUE;
END IF;
END LOOP;
RETURN CASE WHEN v_has_column THEN v_predicate ELSE NULL END;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
PROCEDURE append_permission(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN
RETURN;
@@ -72,16 +149,39 @@ AS
IF p_effect = 'DENY' THEN
IF v_deny_predicate IS NULL THEN
v_deny_predicate := '(' || p_clause || ')';
v_deny_predicate := p_clause;
ELSE
v_deny_predicate := v_deny_predicate || ' OR (' || p_clause || ')';
v_deny_predicate := '(' || v_deny_predicate || ') OR (' || p_clause || ')';
END IF;
ELSIF v_allow_predicate IS NULL THEN
v_allow_predicate := '(' || p_clause || ')';
v_allow_predicate := p_clause;
ELSE
v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')';
v_allow_predicate := '(' || v_allow_predicate || ') OR (' || p_clause || ')';
END IF;
END;
PROCEDURE append_rule(p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN
v_permission_invalid := TRUE;
RETURN;
END IF;
IF v_permission_predicate IS NULL THEN
v_permission_predicate := p_clause;
ELSE
v_permission_predicate := '(' || v_permission_predicate || ') AND (' || p_clause || ')';
END IF;
END;
PROCEDURE flush_permission IS
BEGIN
IF NOT v_permission_invalid THEN
append_permission(v_active_permission_effect, v_permission_predicate);
END IF;
v_permission_predicate := NULL;
v_permission_invalid := FALSE;
END;
BEGIN
BEGIN
v_user_id := TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'));
@@ -111,7 +211,8 @@ BEGIN
ON gr.group_id = ug.group_id
WHERE ug.user_id = v_user_id
)
SELECT UPPER(TRIM(r.rule_type)) AS rule_type,
SELECT p.perm_id,
UPPER(TRIM(r.rule_type)) AS rule_type,
UPPER(TRIM(r.rule_column)) AS rule_column,
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
r.rule_value
@@ -122,148 +223,135 @@ BEGIN
ON r.perm_id = p.perm_id
WHERE p.target_name = v_target
AND p.action_name = 'SELECT'
ORDER BY r.rule_id
ORDER BY p.perm_id, r.rule_id
) LOOP
IF v_active_permission_id IS NULL THEN
v_active_permission_id := r.perm_id;
v_active_permission_effect := r.permission_effect;
ELSIF v_active_permission_id <> r.perm_id THEN
flush_permission;
v_active_permission_id := r.perm_id;
v_active_permission_effect := r.permission_effect;
END IF;
DECLARE
v_column VARCHAR2(128);
v_static_predicate VARCHAR2(32767);
BEGIN
IF r.rule_type = 'ALL' THEN
IF r.permission_effect = 'DENY' THEN
RETURN '1 = 0';
END IF;
v_allow_predicate := '(1 = 1)';
append_rule('1 = 1');
ELSIF r.rule_type = 'MY_DEPT' THEN
-- NULL rule_column keeps backward compatibility with the demo seed.
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
append_rule(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
END IF;
ELSIF r.rule_type = 'SELF' THEN
-- NULL rule_column keeps backward compatibility with the demo seed.
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
append_rule(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
END IF;
ELSIF r.rule_type = 'DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
append_rule(v_column || ' = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type = 'EMP_NO' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
append_rule(v_column || ' = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type = 'TAG' THEN
-- A TAG rule defaults to the ingestion metadata column TECH_TAG.
-- Multiple ALLOW TAG rows are accumulated by append_or, so any
-- matching tag grants visibility. DENY rows are accumulated
-- separately and removed from the final result.
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'TECH_TAG'));
IF v_column IS NOT NULL
AND r.rule_value IS NOT NULL
AND REGEXP_LIKE(TRIM(r.rule_value), '^[A-Za-z0-9_-]+$') THEN
append_or(
r.permission_effect,
append_rule(
'REGEXP_LIKE(UPPER(' || v_column || '), ' ||
quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')'
);
END IF;
ELSIF r.rule_type IN ('STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL') THEN
-- CB_AGENT_CTX_PKG가 Bearer token -> KB_STAKEHOLDERS를 한 번
-- 해석해 USER_ID/ROLE/CHANNEL을 secure context로 설정한다.
-- 따라서 보호 테이블 predicate에서 KB_STAKEHOLDERS를 다시 조인하지
-- 않고, 해당 context와 KB_CONTRACTS 관계만 사용한다.
ELSIF r.rule_type IN (
'TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
) THEN
-- 백오피스 권한 설정의 조건 코드를 안전한 WHERE 조각으로 확장한다.
-- token -> stakeholder 해석은 context package가 한 번만 수행하며,
-- VPD filter는 context와 대상/계약원장만 쓴다.
v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL
AND UPPER(p_schema) = 'POC_2' THEN
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'
IF r.rule_type = 'TOKEN_SUBJECT'
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''))'
append_rule(
'USER_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
);
ELSIF v_target IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
ELSIF r.rule_type = 'OWN_CONTRACT'
AND v_target = 'KB_CONTRACTS'
AND v_column = 'FC_ID' THEN
append_rule(
'FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
);
ELSIF r.rule_type = 'CHANNEL_CONTRACT'
AND v_target = 'KB_CONTRACTS'
AND v_column = 'FC_CHANNEL' THEN
append_rule(
'FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
);
ELSIF r.rule_type IN ('OWN_CUSTOMER', 'CHANNEL_CUSTOMER')
AND 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_subject_filter VARCHAR2(500);
BEGIN
v_contract_key := CASE v_column
WHEN 'CUST_ID' THEN 'CUST_ID'
WHEN 'CONTRACT_NO' THEN 'CONTRACT_NO'
END;
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 || ')'
);
v_subject_filter := CASE r.rule_type
WHEN 'OWN_CUSTOMER' THEN
'c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
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 || ')'
'c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
END;
append_rule(
'EXISTS (SELECT 1 FROM (SELECT c.' || v_contract_key || ' AS access_key ' ||
'FROM POC_2.KB_CONTRACTS c WHERE ' || v_subject_filter ||
') allowed_contract WHERE allowed_contract.access_key = ' || v_column || ')'
);
END IF;
END IF;
END;
END IF;
END IF;
ELSIF r.rule_type = 'STATIC_SQL' THEN
v_static_predicate := safe_static_predicate(p_schema, p_object, r.rule_value);
append_rule(v_static_predicate);
ELSIF r.rule_type = '=' THEN
v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 'TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
append_rule('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type IN ('!=', '<>') THEN
v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 'TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
append_rule('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
END IF;
ELSE
append_rule(NULL);
END IF;
END;
END LOOP;
flush_permission;
IF v_allow_predicate IS NULL THEN
RETURN '1 = 0';
END IF;
IF v_deny_predicate IS NULL THEN
IF v_allow_predicate = '(1 = 1)' THEN
IF v_allow_predicate = '1 = 1' THEN
RETURN '1 = 1';
END IF;
RETURN '(' || v_allow_predicate || ')';

View File

@@ -67,6 +67,12 @@ BEGIN
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456112, 'rule_test_deny_only', 'E456112', 'HR', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456113, 'rule_test_static_and', 'E456113', 'HR', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456114, 'rule_test_static_invalid', 'E456114', 'HR', 'N', 'Y');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456101, 'RULE_TEST_MY_DEPT');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456102, 'RULE_TEST_SELF');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456103, 'RULE_TEST_ALL');
@@ -79,6 +85,8 @@ BEGIN
INSERT INTO cb_app_role(role_id, role_name) VALUES (456110, 'RULE_TEST_TARGET_MISMATCH');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456111, 'RULE_TEST_ALLOW_DENY');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456112, 'RULE_TEST_DENY_ONLY');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456113, 'RULE_TEST_STATIC_AND');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456114, 'RULE_TEST_STATIC_INVALID');
INSERT INTO cb_user_role(user_id, role_id) VALUES (456101, 456101);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102);
@@ -92,6 +100,8 @@ BEGIN
INSERT INTO cb_user_role(user_id, role_id) VALUES (456110, 456110);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456111, 456111);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456112, 456112);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456113, 456113);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456114, 456114);
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
@@ -132,6 +142,12 @@ BEGIN
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456113, 456112, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'DENY');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456114, 456113, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456115, 456114, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456101, 456101, NULL, 'MY_DEPT', NULL);
@@ -171,6 +187,15 @@ BEGIN
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456113, 456113, 'DEPT_CODE', '=', 'HR');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456114, 456114, 'DEPT_CODE', '=', 'HR');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456115, 456114, NULL, 'STATIC_SQL', 'OWNER_EMP_NO = ''E456113''');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456116, 456115, NULL, 'STATIC_SQL', 'DEPT_CODE = ''HR''; 1 = 1');
COMMIT;
END;
/
@@ -260,6 +285,8 @@ BEGIN
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT', 456111, 'AND NOT');
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT_DETAIL', 456111, 'TO_CHAR(DEPT_CODE) = ''HR''');
assert_equals('DENY_ONLY_DENIES', 456112, '1 = 0');
assert_contains('CONTEXT_AND_STATIC_SQL', 456113, 'TO_CHAR(DEPT_CODE) = ''HR'') AND (OWNER_EMP_NO = ''E456113'')');
assert_equals('INVALID_STATIC_SQL_DENIES', 456114, '1 = 0');
cb_agent_ctx_pkg.clear_user;
END;
/

View File

@@ -383,20 +383,18 @@ 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', 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', '지점장');
ensure_permission(v_designer_role_id, 'KB_CONTRACTS', 'FC_ID', 'OWN_CONTRACT', NULL);
ensure_permission(v_manager_role_id, 'KB_CONTRACTS', 'FC_CHANNEL', 'CHANNEL_CONTRACT', NULL);
ensure_permission(v_designer_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'OWN_CUSTOMER', NULL);
ensure_permission(v_manager_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
ensure_permission(v_designer_role_id, 'KB_COVERAGES', 'CUST_ID', 'OWN_CUSTOMER', NULL);
ensure_permission(v_manager_role_id, 'KB_COVERAGES', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
ensure_permission(v_designer_role_id, 'KB_CLAIMS', 'CUST_ID', 'OWN_CUSTOMER', NULL);
ensure_permission(v_manager_role_id, 'KB_CLAIMS', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
ensure_permission(v_designer_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'OWN_CUSTOMER', NULL);
ensure_permission(v_manager_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'CHANNEL_CUSTOMER', NULL);
END;
/
COMMIT;

View File

@@ -81,7 +81,7 @@ BEGIN
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);
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, 'USER_ID', 'TOKEN_SUBJECT', NULL);
END;
END;
/

View File

@@ -0,0 +1,137 @@
-- ============================================================
-- 48_kb_permission_condition_codes.sql
--
-- Replaces the first KB example's stakeholder-specific predicate types with
-- policy condition codes. CB_AGENT_DOC_VPD_FILTER expands these codes into
-- the target object's WHERE predicate.
-- Execute as ADMIN after 25 and 46.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
ALTER SESSION DISABLE PARALLEL DML;
PROMPT === 1. Replacing KB example rules with filter condition codes ===
UPDATE cb_permission_rule r
SET rule_type = 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'
AND p.target_name = 'KB_CONTRACTS'
) THEN 'OWN_CONTRACT'
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'
AND p.target_name = 'KB_CONTRACTS'
) THEN 'CHANNEL_CONTRACT'
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'
AND p.target_name IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
) THEN 'OWN_CUSTOMER'
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'
AND p.target_name IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
) THEN 'CHANNEL_CUSTOMER'
ELSE r.rule_type
END,
rule_value = NULL
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')
AND p.target_name IN ('KB_CONTRACTS', 'KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
);
COMMIT;
PROMPT === 2. Keeping token subject access as an identity-only permission ===
DECLARE
v_identity_role_id NUMBER;
v_permission_id NUMBER;
v_count NUMBER;
BEGIN
BEGIN
SELECT role_id INTO v_identity_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_identity_role_id FROM cb_app_role;
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
VALUES (v_identity_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_identity_role_id;
IF v_count = 0 THEN
INSERT INTO cb_user_role(user_id, role_id) VALUES (u.user_id, v_identity_role_id);
END IF;
END LOOP;
-- Business roles do not need to grant the stakeholder source row. The
-- identity role alone lets a valid token resolve its own stakeholder row.
DELETE FROM cb_permission_column
WHERE permission_id IN (
SELECT p.perm_id
FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
WHERE ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
AND p.target_name = 'KB_STAKEHOLDERS'
);
DELETE FROM cb_permission_rule
WHERE perm_id IN (
SELECT p.perm_id
FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
WHERE ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
AND p.target_name = 'KB_STAKEHOLDERS'
);
DELETE FROM cb_permission
WHERE perm_id IN (
SELECT p.perm_id
FROM cb_permission p JOIN cb_app_role ar ON ar.role_id = p.role_id
WHERE ar.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
AND p.target_name = 'KB_STAKEHOLDERS'
);
BEGIN
SELECT perm_id INTO v_permission_id
FROM cb_permission
WHERE role_id = v_identity_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_identity_role_id, 'KB_STAKEHOLDERS', 'SELECT', 'ALLOW');
END;
DELETE FROM cb_permission_rule WHERE perm_id = v_permission_id;
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', 'TOKEN_SUBJECT', NULL);
END;
/
COMMIT;
PROMPT === 3. Condition codes consumed by the VPD filter ===
SELECT ar.role_name, p.target_name, r.rule_column, r.rule_type
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 permission condition codes ready ===

View File

@@ -0,0 +1,35 @@
-- ============================================================
-- 49_permission_static_sql_condition.sql
--
-- Makes room for a literal, static WHERE condition attached to an
-- allowlisted permission rule. The VPD function validates the expression
-- again before returning it as part of a predicate.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Enlarging rule value for static SQL conditions ===
DECLARE
v_data_length NUMBER;
BEGIN
SELECT data_length
INTO v_data_length
FROM user_tab_columns
WHERE table_name = 'CB_PERMISSION_RULE'
AND column_name = 'RULE_VALUE';
IF v_data_length < 1000 THEN
EXECUTE IMMEDIATE
'ALTER TABLE cb_permission_rule MODIFY (rule_value VARCHAR2(1000 CHAR))';
END IF;
END;
/
SELECT column_name, data_type, char_length
FROM user_tab_columns
WHERE table_name = 'CB_PERMISSION_RULE'
AND column_name = 'RULE_VALUE';
PROMPT === Static SQL condition storage ready ===

View File

@@ -0,0 +1,229 @@
-- ============================================================
-- 51_kb_vpd_admin_token.sql
--
-- Creates an application-level KB VPD administrator. This is not an
-- EXEMPT ACCESS POLICY database account: the token is resolved to an
-- application context and the existing VPD policy function returns ALL
-- only because KB_VPD_ADMIN_ROLE has explicit ALL permissions.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
SET SERVEROUTPUT ON
PROMPT === Creating KB VPD administrator, permissions, and one-time token ===
GRANT EXECUTE ON cb_agent_ctx_pkg TO POC_2;
DECLARE
c_stakeholder_user_id CONSTANT VARCHAR2(30) := 'KB_VPD_ADMIN';
c_role_name CONSTANT VARCHAR2(80) := 'KB_VPD_ADMIN_ROLE';
c_token_description CONSTANT VARCHAR2(200) := 'KB VPD admin bootstrap token';
v_admin_role_id NUMBER;
v_identity_role_id NUMBER;
v_app_user_id NUMBER;
v_permission_id NUMBER;
v_bearer_token VARCHAR2(128);
v_count NUMBER;
PROCEDURE ensure_role(
p_role_name IN VARCHAR2,
p_level IN VARCHAR2,
p_role_id OUT NUMBER
) IS
BEGIN
SELECT role_id
INTO p_role_id
FROM cb_app_role
WHERE role_name = p_role_name;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(role_id), 0) + 1
INTO p_role_id
FROM cb_app_role;
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
VALUES (p_role_id, p_role_name, p_level);
END;
PROCEDURE ensure_user_role(p_user_id IN NUMBER, p_role_id IN NUMBER) IS
BEGIN
SELECT COUNT(*)
INTO v_count
FROM cb_user_role
WHERE user_id = p_user_id
AND role_id = p_role_id;
IF v_count = 0 THEN
INSERT INTO cb_user_role(user_id, role_id) VALUES (p_user_id, p_role_id);
END IF;
END;
PROCEDURE ensure_all_permission(p_target_name IN VARCHAR2) IS
BEGIN
SELECT MIN(perm_id)
INTO v_permission_id
FROM cb_permission
WHERE role_id = v_admin_role_id
AND target_name = p_target_name
AND action_name = 'SELECT';
IF v_permission_id IS NULL 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_admin_role_id, p_target_name, 'SELECT', 'ALLOW'
);
ELSE
UPDATE cb_permission
SET permission_effect = 'ALLOW'
WHERE perm_id = v_permission_id;
END IF;
DELETE FROM cb_permission_rule WHERE perm_id = v_permission_id;
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, NULL, 'ALL', NULL);
-- CB_ORDS is the non-exempt data runtime. VPD still applies after
-- this object privilege is granted.
EXECUTE IMMEDIATE
'GRANT SELECT ON POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_target_name) || ' TO CB_ORDS';
END;
PROCEDURE allow_original_column(
p_target_name IN VARCHAR2,
p_column_name IN VARCHAR2
) IS
BEGIN
SELECT MIN(perm_id)
INTO v_permission_id
FROM cb_permission
WHERE role_id = v_admin_role_id
AND target_name = p_target_name
AND action_name = 'SELECT';
SELECT COUNT(*)
INTO v_count
FROM cb_permission_column
WHERE permission_id = v_permission_id
AND column_name = p_column_name;
IF v_count = 0 THEN
INSERT INTO cb_permission_column(permission_id, column_name)
VALUES (v_permission_id, p_column_name);
END IF;
END;
BEGIN
MERGE INTO poc_2.kb_stakeholders target
USING (
SELECT c_stakeholder_user_id AS user_id,
'VPD 관리자' AS user_nm,
'관리자' AS role,
'관리자채널' AS channel,
'전체 KB 원장 및 민감 컬럼 접근' AS access_scope,
'권한 테이블의 KB_VPD_ADMIN_ROLE / ALL 규칙으로 적용' AS rls_filter
FROM dual
) source
ON (target.user_id = source.user_id)
WHEN MATCHED THEN UPDATE SET
target.user_nm = source.user_nm,
target.role = source.role,
target.channel = source.channel,
target.access_scope = source.access_scope,
target.rls_filter = source.rls_filter
WHEN NOT MATCHED THEN INSERT (
user_id, user_nm, role, channel, access_scope, rls_filter
) VALUES (
source.user_id, source.user_nm, source.role, source.channel,
source.access_scope, source.rls_filter
);
BEGIN
SELECT user_id
INTO v_app_user_id
FROM cb_app_user
WHERE stakeholder_user_id = c_stakeholder_user_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(user_id), 0) + 1 INTO v_app_user_id FROM cb_app_user;
INSERT INTO cb_app_user(
user_id, user_name, employee_no, dept_code,
stakeholder_user_id, can_read_contents, active
) VALUES (
v_app_user_id, 'VPD 관리자', c_stakeholder_user_id, '관리자채널',
c_stakeholder_user_id, 'Y', 'Y'
);
END;
UPDATE cb_app_user
SET user_name = 'VPD 관리자',
employee_no = c_stakeholder_user_id,
dept_code = '관리자채널',
can_read_contents = 'Y',
active = 'Y'
WHERE user_id = v_app_user_id;
ensure_role(c_role_name, 'RESTRICTED', v_admin_role_id);
ensure_role('KB_STAKEHOLDER_IDENTITY_ROLE', 'INTERNAL', v_identity_role_id);
ensure_user_role(v_app_user_id, v_admin_role_id);
ensure_user_role(v_app_user_id, v_identity_role_id);
FOR object_name IN (
SELECT column_value
FROM TABLE(sys.odcivarchar2list(
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
))
) LOOP
ensure_all_permission(object_name.column_value);
END LOOP;
allow_original_column('KB_CUSTOMERS', 'CUST_NM');
allow_original_column('KB_CUSTOMERS', 'RRN_MASKED');
allow_original_column('KB_CLAIMS', 'CLAIM_AMT');
allow_original_column('KB_CLAIMS', 'PAID_AMT');
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER');
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP');
allow_original_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE');
-- Re-running this script rotates only the bootstrap administrator token.
UPDATE cb_agent_bearer_key
SET active = 'N',
revoked_at = SYSDATE
WHERE stakeholder_user_id = c_stakeholder_user_id
AND description = c_token_description
AND active = 'Y';
v_bearer_token := 'kbvpdadm_' || DBMS_RANDOM.STRING('x', 48);
INSERT INTO cb_agent_bearer_key(
key_id, user_id, stakeholder_user_id, key_prefix, key_hash,
issued_at, expires_at, revoked_at, active, description
) VALUES (
cb_agent_bearer_key_seq.NEXTVAL, v_app_user_id, c_stakeholder_user_id,
'kbvpdadm', STANDARD_HASH(v_bearer_token, 'SHA256'),
SYSDATE, ADD_MONTHS(SYSDATE, 12), NULL, 'Y', c_token_description
);
COMMIT;
-- This is the application step that binds the Bearer Token to the trusted
-- context. DBMS_RLS then invokes CB_AGENT_DOC_VPD_FILTER automatically.
cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_token);
DBMS_OUTPUT.PUT_LINE('STAKEHOLDER_USER_ID='
|| SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'));
DBMS_OUTPUT.PUT_LINE('EFFECTIVE_ROLE=' || c_role_name);
DBMS_OUTPUT.PUT_LINE('KB_CUSTOMERS_PREDICATE='
|| cb_agent_doc_vpd_filter('POC_2', 'KB_CUSTOMERS'));
DBMS_OUTPUT.PUT_LINE('KB_CLAIMS_PREDICATE='
|| cb_agent_doc_vpd_filter('POC_2', 'KB_CLAIMS'));
DBMS_OUTPUT.PUT_LINE('KB_EXTERNAL_HOLDINGS_PREDICATE='
|| cb_agent_doc_vpd_filter('POC_2', 'KB_EXTERNAL_HOLDINGS'));
cb_agent_ctx_pkg.clear_user;
-- Show once. The raw token is never stored; only its SHA-256 hash remains.
DBMS_OUTPUT.PUT_LINE('BEARER_TOKEN=' || v_bearer_token);
END;
/
PROMPT === KB VPD administrator token ready ===

View File

@@ -0,0 +1,223 @@
-- ============================================================
-- 52_cleanup_to_kb_vpd_poc.sql
--
-- Removes legacy document/Vector/DDS/test examples and keeps only
-- the KB structured-data VPD POC:
-- POC_2.KB_* seven ledgers, stakeholder identity, KB roles,
-- bearer-token permission metadata, and their VPD policies.
--
-- Oracle-maintained ANNOTATIONS_* / VECTOR$* auxiliary objects are
-- not dropped directly. Their owning RAG/vector tables are dropped.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
SET SERVEROUTPUT ON
ALTER SESSION DISABLE PARALLEL DML;
PROMPT === 1. Removing document, legacy role, and test permissions ===
DECLARE
c_role_keep_1 CONSTANT VARCHAR2(80) := 'KB_DESIGNER_ROLE';
c_role_keep_2 CONSTANT VARCHAR2(80) := 'KB_BRANCH_MANAGER_ROLE';
c_role_keep_3 CONSTANT VARCHAR2(80) := 'KB_STAKEHOLDER_IDENTITY_ROLE';
c_role_keep_4 CONSTANT VARCHAR2(80) := 'KB_VPD_ADMIN_ROLE';
BEGIN
DELETE FROM cb_permission_column
WHERE permission_id IN (
SELECT p.perm_id
FROM cb_permission p
JOIN cb_app_role r ON r.role_id = p.role_id
WHERE r.role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
OR p.target_name NOT IN (
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
)
);
DELETE FROM cb_permission_rule
WHERE perm_id IN (
SELECT p.perm_id
FROM cb_permission p
JOIN cb_app_role r ON r.role_id = p.role_id
WHERE r.role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
OR p.target_name NOT IN (
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
)
);
DELETE FROM cb_permission
WHERE perm_id IN (
SELECT p.perm_id
FROM cb_permission p
JOIN cb_app_role r ON r.role_id = p.role_id
WHERE r.role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
OR p.target_name NOT IN (
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
)
);
DELETE FROM cb_user_role
WHERE role_id IN (
SELECT role_id
FROM cb_app_role
WHERE role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4)
);
DELETE FROM cb_group_role;
DELETE FROM cb_user_group;
DELETE FROM cb_app_group;
DELETE FROM cb_app_role
WHERE role_name NOT IN (c_role_keep_1, c_role_keep_2, c_role_keep_3, c_role_keep_4);
-- Keep only tokens attached to an actual KB stakeholder.
DELETE FROM cb_agent_bearer_key
WHERE stakeholder_user_id IS NULL;
-- Non-stakeholder users were document/test sample principals.
DELETE FROM cb_app_user
WHERE stakeholder_user_id IS NULL;
DELETE FROM cb_vpd_filter_note;
DELETE FROM cb_vpd_policy_note;
DELETE FROM cb_ords_probe_audit;
DELETE FROM cb_protected_column
WHERE object_id IN (
SELECT object_id
FROM cb_protected_object
WHERE NOT (
owner = 'POC_2'
AND object_name IN (
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
)
)
);
DELETE FROM cb_protected_object
WHERE NOT (
owner = 'POC_2'
AND object_name IN (
'KB_CUSTOMERS', 'KB_PRODUCTS', 'KB_CONTRACTS', 'KB_COVERAGES',
'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS', 'KB_STAKEHOLDERS'
)
);
COMMIT;
END;
/
PROMPT === 2. Removing legacy document VPD/redaction and source objects ===
BEGIN
DBMS_REDACT.DROP_POLICY(
object_schema => 'ADMIN',
object_name => 'CB_V_SEARCH_DOCUMENTS',
policy_name => 'CB_CONTENTS_REDACT'
);
EXCEPTION
WHEN OTHERS THEN
-- The policy may already be absent in a reduced POC schema.
DBMS_OUTPUT.PUT_LINE('redaction policy not removed: ' || SQLERRM);
END;
/
DECLARE
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM all_policies
WHERE object_owner = 'ADMIN'
AND object_name = 'CB_V_SEARCH_DOCUMENTS'
AND policy_name = 'CB_AGENT_DOC_POLICY';
IF v_count > 0 THEN
DBMS_RLS.DROP_POLICY(
object_schema => 'ADMIN',
object_name => 'CB_V_SEARCH_DOCUMENTS',
policy_name => 'CB_AGENT_DOC_POLICY'
);
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
PROMPT === 3. Dropping unrelated POC_2 backup, RAG, and security-example tables ===
DECLARE
PROCEDURE drop_table_if_exists(p_table_name IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE
'DROP TABLE POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name)
|| ' CASCADE CONSTRAINTS PURGE';
DBMS_OUTPUT.PUT_LINE('dropped POC_2.' || p_table_name);
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE = -942 THEN
NULL;
ELSE
RAISE;
END IF;
END;
BEGIN
drop_table_if_exists('BKP_CLM_20260701144052');
drop_table_if_exists('BKP_CONT_20260701144052');
drop_table_if_exists('BKP_COV_20260701144052');
drop_table_if_exists('BKP_CUST_20260701144052');
drop_table_if_exists('BKP_EXT_20260701144052');
drop_table_if_exists('BKP_PROD_20260701144052');
drop_table_if_exists('KB_CUST_BAK_202607011343');
drop_table_if_exists('KB_CUST_BAK_20260701134439');
drop_table_if_exists('KB_AIDP_RAG_CORPUS_CONFIG');
drop_table_if_exists('KB_AIDP_RAG_QUERY_CACHE');
drop_table_if_exists('KB_COMPETITOR_TERMS_CHUNKS');
drop_table_if_exists('KB_COMPETITOR_TERMS_DOCUMENTS');
drop_table_if_exists('KB_OWN_TERMS_CHUNKS');
drop_table_if_exists('KB_OWN_TERMS_DOCUMENTS');
drop_table_if_exists('KB_SECURITY_AUDIT_LOG');
drop_table_if_exists('KB_SECURITY_POLICY_CATALOG');
END;
/
PROMPT === 4. Remaining KB VPD POC inventory ===
SELECT object_name
FROM cb_protected_object
WHERE owner = 'POC_2'
ORDER BY object_name;
SELECT role_name
FROM cb_app_role
ORDER BY role_name;
SELECT key_id, stakeholder_user_id, key_prefix, active, expires_at
FROM cb_agent_bearer_key
ORDER BY key_id;
SELECT object_owner, object_name, policy_name, function, enable
FROM all_policies
WHERE object_owner = 'POC_2'
ORDER BY object_name, policy_name;
PROMPT === KB VPD POC cleanup complete ===

View File

@@ -0,0 +1,101 @@
-- ============================================================
-- 53_drop_legacy_poc_objects.sql
-- Completes the physical-object portion of the KB VPD POC cleanup
-- after 52 has removed legacy permissions, roles, tokens and users.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
SET SERVEROUTPUT ON
PROMPT === Removing legacy document VPD/redaction and source objects ===
BEGIN
DBMS_REDACT.DROP_POLICY(
object_schema => 'ADMIN',
object_name => 'CB_V_SEARCH_DOCUMENTS',
policy_name => 'CB_CONTENTS_REDACT'
);
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('redaction policy not removed: ' || SQLERRM);
END;
/
DECLARE
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM all_policies
WHERE object_owner = 'ADMIN'
AND object_name = 'CB_V_SEARCH_DOCUMENTS'
AND policy_name = 'CB_AGENT_DOC_POLICY';
IF v_count > 0 THEN
DBMS_RLS.DROP_POLICY(
object_schema => 'ADMIN',
object_name => 'CB_V_SEARCH_DOCUMENTS',
policy_name => 'CB_AGENT_DOC_POLICY'
);
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
PROMPT === Dropping unrelated POC_2 backup, RAG, and security-example tables ===
DECLARE
PROCEDURE drop_table_if_exists(p_table_name IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE
'DROP TABLE POC_2.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name)
|| ' CASCADE CONSTRAINTS PURGE';
DBMS_OUTPUT.PUT_LINE('dropped POC_2.' || p_table_name);
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE = -942 THEN
NULL;
ELSE
RAISE;
END IF;
END;
BEGIN
drop_table_if_exists('BKP_CLM_20260701144052');
drop_table_if_exists('BKP_CONT_20260701144052');
drop_table_if_exists('BKP_COV_20260701144052');
drop_table_if_exists('BKP_CUST_20260701144052');
drop_table_if_exists('BKP_EXT_20260701144052');
drop_table_if_exists('BKP_PROD_20260701144052');
drop_table_if_exists('KB_CUST_BAK_202607011343');
drop_table_if_exists('KB_CUST_BAK_20260701134439');
drop_table_if_exists('KB_AIDP_RAG_CORPUS_CONFIG');
drop_table_if_exists('KB_AIDP_RAG_QUERY_CACHE');
drop_table_if_exists('KB_COMPETITOR_TERMS_CHUNKS');
drop_table_if_exists('KB_COMPETITOR_TERMS_DOCUMENTS');
drop_table_if_exists('KB_OWN_TERMS_CHUNKS');
drop_table_if_exists('KB_OWN_TERMS_DOCUMENTS');
drop_table_if_exists('KB_SECURITY_AUDIT_LOG');
drop_table_if_exists('KB_SECURITY_POLICY_CATALOG');
END;
/
PROMPT === Legacy physical objects removed ===

View File

@@ -0,0 +1,47 @@
-- ============================================================
-- 54_drop_legacy_dds_ords_examples.sql
-- Removes only the former document-search ORDS/DDS example objects.
-- It deliberately keeps CB_ORDS because it is the non-exempt KB VPD
-- runtime principal.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
SET SERVEROUTPUT ON
BEGIN
ORDS.DELETE_MODULE(p_module_name => 'cb.agent.security');
DBMS_OUTPUT.PUT_LINE('dropped ORDS module cb.agent.security');
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('ORDS module not removed: ' || SQLERRM);
END;
/
DECLARE
PROCEDURE drop_ddl_if_present(p_ddl IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE p_ddl;
DBMS_OUTPUT.PUT_LINE('dropped: ' || p_ddl);
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('not present: ' || p_ddl);
END;
BEGIN
drop_ddl_if_present('DROP DATA GRANT admin.cb_dg_hr_docs');
drop_ddl_if_present('DROP DATA GRANT admin.cb_dg_fin_docs');
drop_ddl_if_present('DROP DATA GRANT admin.cb_dg_all_docs');
drop_ddl_if_present('DROP END USER "cb_dds_hr"');
drop_ddl_if_present('DROP END USER "cb_dds_fin"');
drop_ddl_if_present('DROP END USER "cb_dds_all"');
drop_ddl_if_present('DROP END USER "cb_dds_none"');
drop_ddl_if_present('DROP DATA ROLE cb_dds_hr_role');
drop_ddl_if_present('DROP DATA ROLE cb_dds_fin_role');
drop_ddl_if_present('DROP DATA ROLE cb_dds_all_role');
drop_ddl_if_present('DROP DATA ROLE cb_dds_connect_only_role');
drop_ddl_if_present('DROP ROLE cb_dds_connect_role');
END;
/
PROMPT === Legacy DDS/ORDS examples removed ===

View File

@@ -634,17 +634,19 @@ public class OrdsProbeService {
List<String> masked = new ArrayList<>();
for (ProtectedColumn column : sensitiveColumns) {
boolean present = false;
boolean allNull = true;
for (Map<String, Object> row : rows) {
for (Map.Entry<String, Object> entry : row.entrySet()) {
if (entry.getKey().equalsIgnoreCase(column.columnName())) {
present = true;
allNull = allNull && entry.getValue() == null;
}
}
}
if (present && allNull) {
// APEX_JSON omits a ref-cursor column whose value is NULL. The KB
// object handlers always select every registered protected column, so
// a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller.
if (allNull) {
masked.add(column.columnName().toLowerCase(Locale.ROOT) + " [" + column.policyLabel() + "]");
}
}

View File

@@ -13,6 +13,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -22,11 +24,21 @@ public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of(
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG",
"STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
"STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL",
"TOKEN_SUBJECT", "OWN_CONTRACT", "CHANNEL_CONTRACT", "OWN_CUSTOMER", "CHANNEL_CUSTOMER",
"STATIC_SQL");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
"=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
"=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "STATIC_SQL");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(
"MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
"MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STATIC_SQL");
private static final Set<String> STATIC_SQL_KEYWORDS = Set.of(
"AND", "OR", "NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "ESCAPE", "TRUE", "FALSE",
"UPPER", "LOWER", "TRIM", "NVL", "COALESCE", "TO_CHAR", "TO_DATE", "REGEXP_LIKE", "INSTR",
"LENGTH", "SUBSTR", "REPLACE", "CAST", "AS", "DATE", "TIMESTAMP", "NUMBER", "VARCHAR2", "CHAR");
private static final Pattern SQL_STRING_LITERAL = Pattern.compile("'(?:''|[^'])*'");
private static final Pattern SQL_IDENTIFIER = Pattern.compile("[A-Z_$#][A-Z0-9_$#]*");
private static final Pattern SQL_QUALIFIED_IDENTIFIER =
Pattern.compile("[A-Z_$#][A-Z0-9_$#]*\\s*\\.", Pattern.CASE_INSENSITIVE);
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
@@ -225,12 +237,18 @@ public class PermissionService {
if ("TAG".equals(type) && column == null && !allowedColumns.contains("TECH_TAG")) {
throw new AppException("TAG 규칙의 기본 컬럼 TECH_TAG가 보호 객체에 없습니다. 컬럼을 지정하세요.");
}
if ("STATIC_SQL".equals(type) && column != null) {
throw new AppException("정적 SQL 조건은 컬럼 선택 없이 조건식 전체를 입력하세요.");
}
if (VALUE_REQUIRED_RULE_TYPES.contains(type) && ruleValue.isBlank()) {
throw new AppException(type + " 규칙에는 값이 필요합니다.");
}
if ("TAG".equals(type) && !ruleValue.matches("[A-Z0-9_-]+")) {
throw new AppException("TAG 값은 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다.");
}
if ("STATIC_SQL".equals(type)) {
validateStaticSqlPredicate(ruleValue, allowedColumns);
}
}
if (hasAll && rules.size() > 1) {
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
@@ -245,6 +263,41 @@ public class PermissionService {
return allowed;
}
private void validateStaticSqlPredicate(String predicate, Set<String> allowedColumns) {
if (predicate.length() > 1000) {
throw new AppException("정적 SQL 조건은 1,000자 이하여야 합니다.");
}
if (predicate.chars().filter(value -> value == '\'').count() % 2 != 0) {
throw new AppException("정적 SQL 조건의 문자열 따옴표가 닫히지 않았습니다.");
}
if (predicate.indexOf(';') >= 0 || predicate.contains("--") || predicate.contains("/*")
|| predicate.contains("*/") || predicate.indexOf(':') >= 0 || predicate.indexOf('@') >= 0
|| predicate.contains("||") || predicate.chars().anyMatch(Character::isISOControl)) {
throw new AppException("정적 SQL 조건에는 단일 WHERE 조건식만 입력할 수 있습니다.");
}
String lexical = SQL_STRING_LITERAL.matcher(predicate).replaceAll(" ");
if (SQL_QUALIFIED_IDENTIFIER.matcher(lexical).find()) {
throw new AppException("정적 SQL 조건에는 다른 테이블·스키마를 참조할 수 없습니다.");
}
boolean hasObjectColumn = false;
Matcher matcher = SQL_IDENTIFIER.matcher(lexical.toUpperCase(Locale.ROOT));
while (matcher.find()) {
String token = matcher.group();
if (STATIC_SQL_KEYWORDS.contains(token)) {
continue;
}
if (!allowedColumns.contains(token)) {
throw new AppException("정적 SQL 조건에는 보호 객체 컬럼과 허용된 SQL 연산자만 사용할 수 있습니다: " + token);
}
hasObjectColumn = true;
}
if (!hasObjectColumn) {
throw new AppException("정적 SQL 조건에는 보호 객체 컬럼이 하나 이상 필요합니다.");
}
}
private void validateVisibleColumns(long objectId, List<String> visibleColumns) {
if (visibleColumns == null || visibleColumns.isEmpty()) {
return;

View File

@@ -66,7 +66,7 @@
SELECT p.perm_id AS permission_id,
r.role_id,
r.role_name,
o.object_id,
NVL(o.object_id, -1) AS object_id,
p.target_name AS object_name,
p.action_name AS action,
NVL(p.permission_effect, 'ALLOW') AS permission_effect,
@@ -93,27 +93,24 @@
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' 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
WHEN pr2.rule_type = 'TOKEN_SUBJECT' THEN
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
WHEN pr2.rule_type = 'OWN_CONTRACT' THEN
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
WHEN pr2.rule_type = 'CHANNEL_CONTRACT' THEN
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
WHEN pr2.rule_type = 'OWN_CUSTOMER' 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
WHEN pr2.rule_type = 'CHANNEL_CUSTOMER' 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''))'
WHEN pr2.rule_type = 'STATIC_SQL' THEN pr2.rule_value
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
END,
CHR(10) || 'OR '
CHR(10) || 'AND '
) WITHIN GROUP (ORDER BY pr2.rule_id)
FROM cb_permission_rule pr2
WHERE pr2.perm_id = p.perm_id

View File

@@ -388,8 +388,8 @@ function syncRuleTypeHints(root = document) {
}
const type = typeSelect.value;
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 valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'STATIC_SQL'].includes(type);
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
const placeholderByType = {
ALL: '값 불필요',
MY_DEPT: '값 불필요',
@@ -399,17 +399,30 @@ function syncRuleTypeHints(root = document) {
TAG: '예: SPRING_BOOT',
STAKEHOLDER_SELF: '예: 설계사',
STAKEHOLDER_CHANNEL: '예: 지점장',
TOKEN_SUBJECT: '값 불필요',
OWN_CONTRACT: '값 불필요',
CHANNEL_CONTRACT: '값 불필요',
OWN_CUSTOMER: '값 불필요',
CHANNEL_CUSTOMER: '값 불필요',
STATIC_SQL: "예: CONTRACT_STATUS = '정상'",
'=': '비교 값',
'!=': '비교 값'
};
columnSelect.required = !columnOptional;
columnSelect.disabled = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
valueInput.required = valueRequired;
valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF'].includes(type);
valueInput.disabled = [
'ALL', 'MY_DEPT', 'SELF', 'TOKEN_SUBJECT', 'OWN_CONTRACT',
'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
].includes(type);
valueInput.placeholder = placeholderByType[type] || '비교 값';
if (valueInput.disabled) {
valueInput.value = '';
}
if (columnSelect.disabled) {
columnSelect.value = '';
}
});
}
@@ -700,17 +713,29 @@ function collectWizardRules(root) {
TAG: 'TECH_TAG',
STAKEHOLDER_SELF: '선택 컬럼',
STAKEHOLDER_CHANNEL: '선택 컬럼',
TOKEN_SUBJECT: '선택 컬럼',
OWN_CONTRACT: '선택 컬럼',
CHANNEL_CONTRACT: '선택 컬럼',
OWN_CUSTOMER: '선택 컬럼',
CHANNEL_CUSTOMER: '선택 컬럼',
STATIC_SQL: '',
ALL: ''
}[type] || '';
if (type === 'ALL') {
return 'ALL';
}
if (type === 'STATIC_SQL') {
return `정적 SQL: ${value}`;
}
if (['MY_DEPT', 'SELF'].includes(type)) {
return `${displayColumn} ${type}`;
}
if (['STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) {
return `${displayColumn} ${type} 역할=${value}`;
}
if (['TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'].includes(type)) {
return `${displayColumn} ${type}`;
}
if (type === 'TAG') {
return `${displayColumn} TAG ${value.toUpperCase()}`;
}
@@ -731,11 +756,20 @@ function collectWizardPredicates(root) {
TAG: 'TECH_TAG',
STAKEHOLDER_SELF: '선택 컬럼',
STAKEHOLDER_CHANNEL: '선택 컬럼',
TOKEN_SUBJECT: '선택 컬럼',
OWN_CONTRACT: '선택 컬럼',
CHANNEL_CONTRACT: '선택 컬럼',
OWN_CUSTOMER: '선택 컬럼',
CHANNEL_CUSTOMER: '선택 컬럼',
STATIC_SQL: '',
ALL: ''
}[type] || '';
if (type === 'ALL') {
return '1 = 1';
}
if (type === 'STATIC_SQL') {
return value;
}
if (type === 'MY_DEPT') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')`;
}
@@ -748,6 +782,21 @@ function collectWizardPredicates(root) {
if (type === 'STAKEHOLDER_CHANNEL') {
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`;
}
if (type === 'TOKEN_SUBJECT') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
}
if (type === 'OWN_CONTRACT') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
}
if (type === 'CHANNEL_CONTRACT') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
}
if (type === 'OWN_CUSTOMER') {
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'))`;
}
if (type === 'CHANNEL_CUSTOMER') {
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL'))`;
}
if (type === 'DEPT' || type === 'EMP_NO') {
return `${displayColumn} = ${sqlLiteral(value)}`;
}
@@ -794,8 +843,8 @@ function updatePermissionWizardPreview(root = document) {
? `거부 규칙: ${ruleText}`
: `허용 규칙: ${ruleText}`;
const predicateText = effect === 'DENY'
? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' OR ') || '조건 없음'})`)
: `ALLOW 후보: ${predicates.join(' OR ') || '조건 없음'}`;
? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' AND ') || '조건 없음'})`)
: `ALLOW 후보: ${predicates.join(' AND ') || '조건 없음'}`;
const columnPolicy = visibleColumns
? `이 권한에서 원문 표시 허용: ${visibleColumns}`
: '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리';
@@ -872,19 +921,25 @@ function permissionWizardRuleValidationMessage(wizard) {
if (values.some((rule) => rule.type === 'ALL') && values.length > 1) {
return 'ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다. ALL만 남기거나 ALL을 삭제하세요.';
}
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'];
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'STATIC_SQL'];
const seen = new Set();
for (const [index, rule] of values.entries()) {
const position = index + 1;
if (!rule.type) {
return `${position}번째 행 규칙 유형을 선택하세요.`;
}
if (['=', '!=', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(rule.type) && !rule.column) {
if ([
'=', '!=', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'TOKEN_SUBJECT',
'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
].includes(rule.type) && !rule.column) {
return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`;
}
if (valueRequired.includes(rule.type) && !rule.value) {
return `${position}번째 ${rule.type} 규칙의 값을 입력하세요.`;
}
if (rule.type === 'STATIC_SQL' && rule.column) {
return `${position}번째 정적 SQL 조건은 컬럼을 따로 선택하지 마세요.`;
}
if (rule.type === 'TAG' && rule.value && !/^[A-Za-z0-9_-]+$/.test(rule.value)) {
return `${position}번째 TAG 값은 영문·숫자, '_' 또는 '-'만 사용할 수 있습니다.`;
}

View File

@@ -8,7 +8,7 @@
<h1>접근 규칙</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제는 토큰 주체에서 한 번 해석한 이해당사자 ID·역할·채널을 secure context로 사용하고, 대상 고객은 계약원장으로만 연결합니다. <code>STAKEHOLDER_SELF</code><code>STAKEHOLDER_CHANNEL</code>은 각각의 역할 분기로 만들고 허용 분기끼리는 OR로 계산합니다.</p>
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 조건 코드는 토큰 context 값으로 치환되고, 정적 SQL 조건은 대상 객체의 WHERE 절에 그대로 추가됩니다. 한 권한의 규칙은 AND, 서로 다른 ALLOW 권한은 OR로 합치며 DENY는 허용 결과에서 제외합니다.</p>
</details>
</div>
@@ -154,31 +154,32 @@
<div id="rowRuleList" class="rule-list">
<div class="rule-row">
<select class="form-select rule-column-select" name="ruleColumn">
<option value="">기본 컬럼</option>
<option value="DEPT_CODE">DEPT_CODE</option>
<option value="OWNER_EMP_NO">OWNER_EMP_NO</option>
<option value="DOC_ID">DOC_ID</option>
<option value="TITLE">TITLE</option>
<option value="">객체 컬럼 선택</option>
</select>
<select class="form-select rule-type-select" name="ruleType">
<option value="ALL">ALL</option>
<option value="MY_DEPT">현재 사용자 부서</option>
<option value="SELF">현재 사용자 사번</option>
<option value="DEPT">지정 부서</option>
<option value="EMP_NO">지정 사번</option>
<option value="TAG">특정 기술 태그</option>
<option value="STAKEHOLDER_SELF">이해당사자 본인 담당</option>
<option value="STAKEHOLDER_CHANNEL">이해당사자 채널 범위</option>
<option value="=">=</option>
<option value="!=">!=</option>
<optgroup label="KB · 본인 범위">
<option value="OWN_CONTRACT">본인 담당 계약</option>
<option value="OWN_CUSTOMER">본인 담당 고객</option>
</optgroup>
<optgroup label="KB · 채널 범위">
<option value="CHANNEL_CONTRACT">토큰 채널 계약</option>
<option value="CHANNEL_CUSTOMER">토큰 채널 고객</option>
</optgroup>
<optgroup label="KB · 토큰 식별">
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
</optgroup>
<optgroup label="SQL · 정적 조건">
<option value="STATIC_SQL">정적 SQL 조건식</option>
</optgroup>
</select>
<input class="form-control" name="ruleValue" placeholder="비교 값">
<input class="form-control" name="ruleValue" maxlength="1000" placeholder="비교 값">
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>
</div>
</div>
<details class="explanation-details">
<summary>행 규칙과 기술 태그 OR 조건 보기</summary>
<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>
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong><code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 VPD WHERE 절에 직접 들어가지 않습니다.</p>
</details>
</div>
</div>
@@ -241,7 +242,7 @@
<section class="content-band">
<h2>접근 규칙 목록</h2>
<p class="section-subtitle">같은 보호 대상에 적용되는 ALLOW 역할 분기와 한 권한 안의 여러 행 규칙은 OR로 합칩니다. 예를 들어 설계사 본인계약 분기 또는 지점장 채널 분기 중 하나라도 성립하면 행이 허용됩니다. DENY 분기는 허용 결과에서 별도로 제외됩니다.</p>
<p class="section-subtitle">한 접근 규칙 안에서는 조건 코드와 정적 SQL 조건을 AND로 합쳐 범위를 좁힙니다. 같은 보호 대상의 서로 다른 ALLOW 권한은 OR, DENY 권한은 허용 결과에서 제외됩니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle permission-list-table">
<thead>

View File

@@ -221,6 +221,83 @@ class PermissionServiceTest {
.hasMessageContaining("값이 필요");
}
@Test
void acceptsKbFilterConditionCodesWithoutAStaticRuleValue() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(
new RuleCommand("DEPT_CODE", "OWN_CONTRACT", null),
new RuleCommand("OWNER_EMP_NO", "CHANNEL_CUSTOMER", null)
),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("OWN_CONTRACT", "CHANNEL_CUSTOMER");
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleValue)
.containsExactly("", "");
}
@Test
void acceptsStaticSqlPredicateAlongsideAContextCondition() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(
new RuleCommand("DEPT_CODE", "OWN_CONTRACT", null),
new RuleCommand(null, "STATIC_SQL", "DEPT_CODE IN ('HR', 'FIN')")
),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("OWN_CONTRACT", "STATIC_SQL");
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleValue)
.containsExactly("", "DEPT_CODE IN ('HR', 'FIN')");
}
@Test
void rejectsStaticSqlStatementOrUnknownColumn() {
var statementCommand = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(new RuleCommand(null, "STATIC_SQL", "DEPT_CODE = 'HR'; DELETE FROM CB_APP_USER")),
List.of()
);
var unknownColumnCommand = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(new RuleCommand(null, "STATIC_SQL", "UNSUPPORTED_COLUMN = 'HR'")),
List.of()
);
assertThatThrownBy(() -> permissionService.savePermissionSet(statementCommand))
.isInstanceOf(AppException.class)
.hasMessageContaining("단일 WHERE");
assertThatThrownBy(() -> permissionService.savePermissionSet(unknownColumnCommand))
.isInstanceOf(AppException.class)
.hasMessageContaining("보호 객체 컬럼");
}
@Test
void acceptsMultipleVisibleColumns() {
var command = new PermissionSetCommand(

View File

@@ -157,14 +157,16 @@ class GuidedFlowTemplateTest {
}
@Test
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
void permissionWizardExplainsConditionCodeAndStaticSqlSemantics() throws IOException {
String html = template("permissions.html");
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html)
.contains("value=\"TAG\">특정 기술 태그")
.contains("TECH_TAG")
.contains("태그를 여러 개 추가하면")
.contains("value=\"OWN_CUSTOMER\">본인 담당 고객")
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
.contains("행 규칙의 두 가지 적용 방식 보기")
.contains("한 권한 안의 규칙은 모두 AND")
.contains("서로 다른 역할의 ALLOW 권한은 OR")
.contains("data-wizard-validation")
.contains("다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다")
.contains("저장 영향")