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 d02ca8a..5543e60 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -99,6 +99,14 @@ AS RETURN NULL; END IF; + -- The unconditional predicate is a deliberately narrow exception to the + -- column-reference rule below. It is used by the explicit KB VPD admin + -- permission and is rendered by the backoffice as the physical condition + -- "1 = 1", not as an implicit bypass. + IF REGEXP_LIKE(v_predicate, '^1[[:space:]]*=[[:space:]]*1$') THEN + RETURN '1 = 1'; + 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, '''(''''|[^''])*''', ' '); diff --git a/sql/adb/51_kb_vpd_admin_token.sql b/sql/adb/51_kb_vpd_admin_token.sql index 07ba625..4ce5478 100644 --- a/sql/adb/51_kb_vpd_admin_token.sql +++ b/sql/adb/51_kb_vpd_admin_token.sql @@ -18,7 +18,7 @@ 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'; + c_token_description CONSTANT VARCHAR2(200) := 'KB VPD admin full-access token'; v_admin_role_id NUMBER; v_identity_role_id NUMBER; @@ -83,7 +83,7 @@ DECLARE 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); + VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, NULL, 'STATIC_SQL', '1 = 1'); -- CB_ORDS is the non-exempt data runtime. VPD still applies after -- this object privilege is granted. @@ -187,12 +187,11 @@ BEGIN 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. + -- Re-running this script rotates the only full-access 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); diff --git a/sql/adb/60_kb_vpd_admin_full_access_token.sql b/sql/adb/60_kb_vpd_admin_full_access_token.sql new file mode 100644 index 0000000..2fb2cb9 --- /dev/null +++ b/sql/adb/60_kb_vpd_admin_full_access_token.sql @@ -0,0 +1,198 @@ +-- ============================================================ +-- 60_kb_vpd_admin_full_access_token.sql +-- +-- Creates the KB_VPD_ADMIN application user and grants the explicit +-- physical condition STATIC_SQL / 1 = 1 for every KB protected object. +-- +-- Execute as ADMIN using SQLcl with one positional argument: the newly +-- generated Bearer token. Do not print or commit that argument. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE ON +SET VERIFY OFF +SET SERVEROUTPUT ON + +DEFINE bearer_token = '&1' + +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 full-access token'; + + v_admin_role_id NUMBER; + v_identity_role_id NUMBER; + v_app_user_id NUMBER; + v_permission_id NUMBER; + 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_full_access(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, 'STATIC_SQL', '1 = 1' + ); + + 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, + '모든 보호 객체에 STATIC_SQL / 1 = 1 적용' 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_STAKEHOLDERS', 'KB_CLAIMS', 'KB_CONTRACTS', 'KB_COVERAGES', + 'KB_CUSTOMERS', 'KB_EXTERNAL_HOLDINGS', 'KB_PRODUCTS' + )) + ) LOOP + ensure_full_access(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'); + + UPDATE cb_agent_bearer_key + SET active = 'N', revoked_at = SYSDATE + WHERE stakeholder_user_id = c_stakeholder_user_id + AND active = 'Y'; + + 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('&&bearer_token', 'SHA256'), + SYSDATE, ADD_MONTHS(SYSDATE, 12), NULL, 'Y', c_token_description + ); + + COMMIT; + DBMS_OUTPUT.PUT_LINE('STAKEHOLDER_USER_ID=' || c_stakeholder_user_id); + DBMS_OUTPUT.PUT_LINE('ROLE_NAME=' || c_role_name); + DBMS_OUTPUT.PUT_LINE('OBJECT_PERMISSION_COUNT=7'); +END; +/ + +UNDEFINE bearer_token +EXIT diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index caf3342..07fdd7f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -281,6 +281,13 @@ public class PermissionService { throw new AppException("정적 SQL 조건에는 다른 테이블·스키마를 참조할 수 없습니다."); } + // The VPD function makes the same narrowly-scoped exception. It lets an + // administrator express the auditable physical condition "1 = 1" for a + // full-access permission instead of relying on an implicit ALL marker. + if (predicate.matches("(?i)^1\\s*=\\s*1$")) { + return; + } + boolean hasObjectColumn = false; Matcher matcher = SQL_IDENTIFIER.matcher(lexical.toUpperCase(Locale.ROOT)); while (matcher.find()) { diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java index 90faacc..89d1c8c 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; import com.cloudhandson.vpdbackoffice.domain.permission.AppRole; @@ -271,6 +272,25 @@ class PermissionServiceTest { .containsExactly("", "DEPT_CODE IN ('HR', 'FIN')"); } + @Test + void acceptsExplicitUnconditionalStaticSqlPredicateForAdminRole() { + var command = new PermissionSetCommand( + 10L, + 1L, + "SELECT", + "ALLOW", + List.of(new RuleCommand(null, "STATIC_SQL", "1 = 1")), + List.of() + ); + + permissionService.savePermissionSet(command); + + FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper; + assertThat(mapper.insertedRules) + .extracting(PermissionRule::ruleType, PermissionRule::ruleValue) + .containsExactly(tuple("STATIC_SQL", "1 = 1")); + } + @Test void rejectsStaticSqlStatementOrUnknownColumn() { var statementCommand = new PermissionSetCommand(