diff --git a/docs/design/619-stakeholder-token-permission-example/README.md b/docs/design/619-stakeholder-token-permission-example/README.md index 42cd5fb..2c19b0a 100644 --- a/docs/design/619-stakeholder-token-permission-example/README.md +++ b/docs/design/619-stakeholder-token-permission-example/README.md @@ -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는 후속 범위다. diff --git a/sql/adb/17_agent_ords_security_local_vpd_setup.sql b/sql/adb/17_agent_ords_security_local_vpd_setup.sql index 2536986..1592183 100644 --- a/sql/adb/17_agent_ords_security_local_vpd_setup.sql +++ b/sql/adb/17_agent_ords_security_local_vpd_setup.sql @@ -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 ( 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 238f340..d02ca8a 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -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_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' - 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 + IF r.rule_type = 'TOKEN_SUBJECT' + AND v_target = 'KB_STAKEHOLDERS' + AND v_column = 'USER_ID' THEN + append_rule( + 'USER_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')' + ); + 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 || ')' - ); - 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; + v_subject_filter := CASE r.rule_type + WHEN 'OWN_CUSTOMER' THEN + 'c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')' + ELSE + '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; + 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 || ')'; diff --git a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql b/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql index 7591474..8b6b1e2 100644 --- a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql +++ b/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql @@ -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; / diff --git a/sql/adb/46_kb_stakeholder_token_permission_example.sql b/sql/adb/46_kb_stakeholder_token_permission_example.sql index 0e3dd10..ceb949a 100644 --- a/sql/adb/46_kb_stakeholder_token_permission_example.sql +++ b/sql/adb/46_kb_stakeholder_token_permission_example.sql @@ -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; diff --git a/sql/adb/47_kb_stakeholder_permission_or_branches.sql b/sql/adb/47_kb_stakeholder_permission_or_branches.sql index 070b9c3..18465b9 100644 --- a/sql/adb/47_kb_stakeholder_permission_or_branches.sql +++ b/sql/adb/47_kb_stakeholder_permission_or_branches.sql @@ -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; / diff --git a/sql/adb/48_kb_permission_condition_codes.sql b/sql/adb/48_kb_permission_condition_codes.sql new file mode 100644 index 0000000..e823ede --- /dev/null +++ b/sql/adb/48_kb_permission_condition_codes.sql @@ -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 === diff --git a/sql/adb/49_permission_static_sql_condition.sql b/sql/adb/49_permission_static_sql_condition.sql new file mode 100644 index 0000000..e4f3558 --- /dev/null +++ b/sql/adb/49_permission_static_sql_condition.sql @@ -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 === diff --git a/sql/adb/51_kb_vpd_admin_token.sql b/sql/adb/51_kb_vpd_admin_token.sql new file mode 100644 index 0000000..07ba625 --- /dev/null +++ b/sql/adb/51_kb_vpd_admin_token.sql @@ -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 === diff --git a/sql/adb/52_cleanup_to_kb_vpd_poc.sql b/sql/adb/52_cleanup_to_kb_vpd_poc.sql new file mode 100644 index 0000000..623b298 --- /dev/null +++ b/sql/adb/52_cleanup_to_kb_vpd_poc.sql @@ -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 === diff --git a/sql/adb/53_drop_legacy_poc_objects.sql b/sql/adb/53_drop_legacy_poc_objects.sql new file mode 100644 index 0000000..c792a42 --- /dev/null +++ b/sql/adb/53_drop_legacy_poc_objects.sql @@ -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 === diff --git a/sql/adb/54_drop_legacy_dds_ords_examples.sql b/sql/adb/54_drop_legacy_dds_ords_examples.sql new file mode 100644 index 0000000..2db78e6 --- /dev/null +++ b/sql/adb/54_drop_legacy_dds_ords_examples.sql @@ -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 === diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index 7de6cf1..caf3342 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -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 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 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 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 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 PERMISSION_EFFECTS = Set.of("ALLOW", "DENY"); private static final Set 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 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 visibleColumns) { if (visibleColumns == null || visibleColumns.isEmpty()) { return; diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 5ccbdcd..024ddf1 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -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 diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 25bbc33..9cfe39d 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -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 값은 영문·숫자, '_' 또는 '-'만 사용할 수 있습니다.`; } diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index 7d03046..39c178f 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -8,7 +8,7 @@

접근 규칙

도움말 -

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

+

VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 조건 코드는 토큰 context 값으로 치환되고, 정적 SQL 조건은 대상 객체의 WHERE 절에 그대로 추가됩니다. 한 권한의 규칙은 AND, 서로 다른 ALLOW 권한은 OR로 합치며 DENY는 허용 결과에서 제외합니다.

@@ -154,31 +154,32 @@
- +
- 행 규칙과 기술 태그 OR 조건 보기 -

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

+ 행 규칙의 두 가지 적용 방식 보기 +

조건 코드는 토큰 context로 치환됩니다. 예를 들어 본인 담당 고객 / CUST_ID는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 CUST_ID에 적용합니다. 정적 SQL 조건식CONTRACT_STATUS = '정상'처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 VPD WHERE 절에 직접 들어가지 않습니다.

@@ -241,7 +242,7 @@

접근 규칙 목록

-

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

+

한 접근 규칙 안에서는 조건 코드와 정적 SQL 조건을 AND로 합쳐 범위를 좁힙니다. 같은 보호 대상의 서로 다른 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 77f8e70..90faacc 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java @@ -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( diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index 5ecbeea..f6657cf 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -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("저장 영향")