diff --git a/docs/design/466-permission-deny-priority/README.md b/docs/design/466-permission-deny-priority/README.md new file mode 100644 index 0000000..1520638 --- /dev/null +++ b/docs/design/466-permission-deny-priority/README.md @@ -0,0 +1,30 @@ +# 설계서: 역할/권한 모델에 deny, 우선순위, 충돌 해소 규칙 추가 (#466) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-25 +> **추적성** — Redmine: #466 · 관련 ADR: 없음 +> · 구현 파일: `cb_permission.permission_effect`, `PermissionService`, `PermissionMapper`, `cb_agent_doc_vpd_filter` · 테스트: SQLcl `27`, `mvn test` + +## 1. 목적 + +역할이 여러 개 붙은 사용자에게 allow 권한과 deny 권한이 동시에 존재할 때 최종 접근 범위를 명확히 계산한다. + +## 2. 범위 + +- 포함: permission 단위 `ALLOW/DENY` 효과 추가, deny 우선 VPD predicate 생성, UI 저장/목록 표시, 테스트. +- 제외: role 자체 우선순위 숫자, 승인 workflow, rule별 deny. + +## 3. 인수조건 + +- [ ] `cb_permission.permission_effect`가 `ALLOW` 기본값으로 추가된다. +- [ ] deny permission은 같은 object/action의 allow 결과에서 제외된다. +- [ ] deny `ALL`이 있으면 최종 결과는 `1 = 0`이다. +- [ ] allow가 없으면 deny가 있어도 `1 = 0`이다. +- [ ] UI에서 권한 효과를 선택하고 목록에서 확인할 수 있다. + +## 4. 설계 결정 + +- 효과는 `cb_permission` 단위로 둔다. 하나의 permission set이 allow인지 deny인지 명확히 구분한다. +- 최종 predicate는 `() AND NOT ()`이다. +- allow `ALL`은 `1 = 1`, deny `ALL`은 즉시 `1 = 0`이다. +- deny만 있고 allow가 없으면 whitelist 기본값에 따라 `1 = 0`이다. 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 5179d58..b3c0f95 100644 --- a/sql/adb/17_agent_ords_security_local_vpd_setup.sql +++ b/sql/adb/17_agent_ords_security_local_vpd_setup.sql @@ -65,7 +65,8 @@ CREATE TABLE cb_permission ( perm_id NUMBER PRIMARY KEY, role_id NUMBER NOT NULL REFERENCES cb_app_role(role_id), target_name VARCHAR2(128) NOT NULL, - action_name VARCHAR2(30) NOT NULL + action_name VARCHAR2(30) NOT NULL, + permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL ); CREATE TABLE cb_permission_rule ( @@ -106,9 +107,9 @@ INSERT INTO cb_user_role VALUES (101, 10); INSERT INTO cb_user_role VALUES (102, 20); INSERT INTO cb_user_role VALUES (103, 30); -INSERT INTO cb_permission VALUES (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); -INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); -INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); +INSERT INTO cb_permission VALUES (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'); +INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'); +INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'); INSERT INTO cb_permission_rule VALUES (1000, 100, 'MY_DEPT', NULL); INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL); @@ -256,7 +257,8 @@ AUTHID DEFINER AS v_user_id NUMBER; v_target VARCHAR2(128); - v_predicate VARCHAR2(32767); + v_allow_predicate VARCHAR2(32767); + v_deny_predicate VARCHAR2(32767); FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS BEGIN @@ -294,16 +296,22 @@ AS RETURN NULL; END; - PROCEDURE append_or(p_clause IN VARCHAR2) IS + PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS BEGIN IF p_clause IS NULL THEN RETURN; END IF; - IF v_predicate IS NULL THEN - v_predicate := '(' || p_clause || ')'; + IF p_effect = 'DENY' THEN + IF v_deny_predicate IS NULL THEN + v_deny_predicate := '(' || p_clause || ')'; + ELSE + v_deny_predicate := v_deny_predicate || ' OR (' || p_clause || ')'; + END IF; + ELSIF v_allow_predicate IS NULL THEN + v_allow_predicate := '(' || p_clause || ')'; ELSE - v_predicate := v_predicate || ' OR (' || p_clause || ')'; + v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')'; END IF; END; BEGIN @@ -323,6 +331,7 @@ BEGIN FOR r IN ( SELECT 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 FROM cb_user_role ur JOIN cb_permission p @@ -338,46 +347,56 @@ BEGIN v_column VARCHAR2(128); BEGIN IF r.rule_type = 'ALL' THEN - RETURN '1 = 1'; + IF r.permission_effect = 'DENY' THEN + RETURN '1 = 0'; + END IF; + v_allow_predicate := '(1 = 1)'; ELSIF r.rule_type = 'MY_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(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')'); + append_or(r.permission_effect, v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')'); END IF; ELSIF r.rule_type = 'SELF' 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(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')'); + append_or(r.permission_effect, 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(v_column || ' = ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, 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(v_column || ' = ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value)); END IF; 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('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, '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('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, 'TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value)); END IF; END IF; END; END LOOP; - IF v_predicate IS NULL THEN + IF v_allow_predicate IS NULL THEN RETURN '1 = 0'; END IF; - RETURN '(' || v_predicate || ')'; + IF v_deny_predicate IS NULL THEN + IF v_allow_predicate = '(1 = 1)' THEN + RETURN '1 = 1'; + END IF; + RETURN '(' || v_allow_predicate || ')'; + END IF; + + RETURN '((' || v_allow_predicate || ') AND NOT (' || v_deny_predicate || '))'; END; / diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/sql/adb/25_agent_ords_security_backoffice_support.sql index 811686a..feab13f 100644 --- a/sql/adb/25_agent_ords_security_backoffice_support.sql +++ b/sql/adb/25_agent_ords_security_backoffice_support.sql @@ -52,6 +52,16 @@ EXCEPTION END; / +BEGIN + EXECUTE IMMEDIATE 'ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT ''ALLOW'' NOT NULL)'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -1430 THEN + RAISE; + END IF; +END; +/ + CREATE TABLE cb_protected_object ( object_id NUMBER PRIMARY KEY, owner VARCHAR2(128) NOT NULL, 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 fc35bff..1db0fce 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -22,7 +22,8 @@ AS -- no matched stored row rule means no rows are visible. v_user_id NUMBER; v_target VARCHAR2(128); - v_predicate VARCHAR2(32767); + v_allow_predicate VARCHAR2(32767); + v_deny_predicate VARCHAR2(32767); FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS BEGIN @@ -60,16 +61,22 @@ AS RETURN NULL; END; - PROCEDURE append_or(p_clause IN VARCHAR2) IS + PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS BEGIN IF p_clause IS NULL THEN RETURN; END IF; - IF v_predicate IS NULL THEN - v_predicate := '(' || p_clause || ')'; + IF p_effect = 'DENY' THEN + IF v_deny_predicate IS NULL THEN + v_deny_predicate := '(' || p_clause || ')'; + ELSE + v_deny_predicate := v_deny_predicate || ' OR (' || p_clause || ')'; + END IF; + ELSIF v_allow_predicate IS NULL THEN + v_allow_predicate := '(' || p_clause || ')'; ELSE - v_predicate := v_predicate || ' OR (' || p_clause || ')'; + v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')'; END IF; END; BEGIN @@ -89,6 +96,7 @@ BEGIN FOR r IN ( SELECT 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 FROM cb_user_role ur JOIN cb_permission p @@ -104,48 +112,58 @@ BEGIN v_column VARCHAR2(128); BEGIN IF r.rule_type = 'ALL' THEN - RETURN '1 = 1'; + IF r.permission_effect = 'DENY' THEN + RETURN '1 = 0'; + END IF; + v_allow_predicate := '(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(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')'); + append_or(r.permission_effect, 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(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')'); + append_or(r.permission_effect, 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(v_column || ' = ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, 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(v_column || ' = ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value)); END IF; 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('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, '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('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value)); + append_or(r.permission_effect, 'TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value)); END IF; END IF; END; END LOOP; - IF v_predicate IS NULL THEN + IF v_allow_predicate IS NULL THEN RETURN '1 = 0'; END IF; - RETURN '(' || v_predicate || ')'; + IF v_deny_predicate IS NULL THEN + IF v_allow_predicate = '(1 = 1)' THEN + RETURN '1 = 1'; + END IF; + RETURN '(' || v_allow_predicate || ')'; + END IF; + + RETURN '((' || v_allow_predicate || ') AND NOT (' || v_deny_predicate || '))'; END; / 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 87cd7f0..7591474 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 @@ -61,6 +61,12 @@ BEGIN INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) VALUES (456110, 'rule_test_target_mismatch', 'E456110', 'QA', 'N', 'Y'); + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (456111, 'rule_test_allow_deny', 'E456111', 'HR', 'N', 'Y'); + + 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_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'); @@ -71,6 +77,8 @@ BEGIN INSERT INTO cb_app_role(role_id, role_name) VALUES (456108, 'RULE_TEST_OR_INJECTION'); INSERT INTO cb_app_role(role_id, role_name) VALUES (456109, 'RULE_TEST_COLUMN_INJECTION'); 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_user_role(user_id, role_id) VALUES (456101, 456101); INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102); @@ -82,6 +90,8 @@ BEGIN INSERT INTO cb_user_role(user_id, role_id) VALUES (456108, 456108); INSERT INTO cb_user_role(user_id, role_id) VALUES (456109, 456109); 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_permission(perm_id, role_id, target_name, action_name) VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); @@ -113,6 +123,15 @@ BEGIN INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) VALUES (456110, 456110, 'BOARD_POSTS', 'SELECT'); + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (456111, 456111, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'); + + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (456112, 456111, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'DENY'); + + 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_rule(rule_id, perm_id, rule_column, rule_type, rule_value) VALUES (456101, 456101, NULL, 'MY_DEPT', NULL); @@ -143,6 +162,15 @@ BEGIN INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) VALUES (456110, 456110, NULL, 'ALL', NULL); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456111, 456111, NULL, 'ALL', NULL); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456112, 456112, 'DEPT_CODE', '=', 'HR'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456113, 456113, 'DEPT_CODE', '=', 'HR'); + COMMIT; END; / @@ -229,6 +257,9 @@ BEGIN assert_equals('COLUMN_INJECTION_DENIES', 456109, '1 = 0'); assert_equals_for_object('TARGET_MISMATCH_DENIES', 456110, 'CB_V_SEARCH_DOCUMENTS', '1 = 0'); assert_equals_for_object('TARGET_MATCH_ALL_ALLOWS', 456110, 'BOARD_POSTS', '1 = 1'); + 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'); cb_agent_ctx_pkg.clear_user; END; / diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java index e42812d..ebaa5b7 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java @@ -7,6 +7,7 @@ public record PermissionSet( long roleId, long objectId, String action, + String permissionEffect, List rules, List visibleColumns ) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java index cc338cb..831146c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java @@ -9,6 +9,7 @@ public record PermissionSetCommand( @Positive long roleId, @Positive long objectId, @NotBlank String action, + String permissionEffect, @NotEmpty List rules, List visibleColumns ) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java index d100e98..795b5f0 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java @@ -7,6 +7,7 @@ public record PermissionView( long objectId, String objectName, String action, + String permissionEffect, String rules, String visibleColumns, String filterPreview, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java index 1428a88..63871c4 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java @@ -36,10 +36,14 @@ public interface PermissionMapper { void insertPermission(@Param("permissionId") long permissionId, @Param("roleId") long roleId, @Param("objectId") long objectId, - @Param("action") String action); + @Param("action") String action, + @Param("permissionEffect") String permissionEffect); void updatePermissionAction(@Param("permissionId") long permissionId, @Param("action") String action); + void updatePermissionEffect(@Param("permissionId") long permissionId, + @Param("permissionEffect") String permissionEffect); + void deleteRules(@Param("permissionId") long permissionId); void insertRule(PermissionRule rule); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 49be87e..fa492f2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -56,9 +56,12 @@ public class BackofficeSchemaService { perm_id NUMBER PRIMARY KEY, role_id NUMBER NOT NULL, target_name VARCHAR2(128) NOT NULL, - action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL + action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL, + permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL ) """); + addColumn(results, "cb_permission", "permission_effect", + "ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)"); createTable(results, "cb_permission_rule", """ CREATE TABLE cb_permission_rule ( rule_id NUMBER PRIMARY KEY, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index 7ffda54..9014649 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -22,6 +22,7 @@ public class PermissionService { private static final Set RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO"); private static final Set VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO"); private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO"); + private static final Set PERMISSION_EFFECTS = Set.of("ALLOW", "DENY"); private final PermissionMapper permissionMapper; private final ProtectedObjectService protectedObjectService; @@ -69,6 +70,7 @@ public class PermissionService { if (!"SELECT".equalsIgnoreCase(command.action())) { throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다."); } + String permissionEffect = normalizePermissionEffect(command.permissionEffect()); AppRole role = permissionMapper.findRole(command.roleId()); if (role == null) { throw new AppException("역할을 찾을 수 없습니다."); @@ -80,9 +82,10 @@ public class PermissionService { Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId()); long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId; if (existingId == null) { - permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT"); + permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect); } else { permissionMapper.updatePermissionAction(permissionId, "SELECT"); + permissionMapper.updatePermissionEffect(permissionId, permissionEffect); } permissionMapper.deleteRules(permissionId); @@ -107,7 +110,7 @@ public class PermissionService { "PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null, "roleId=" + command.roleId() )); - return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", List.of(), List.of()); + return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of()); } @Transactional @@ -185,6 +188,14 @@ public class PermissionService { return clean(value).toUpperCase(Locale.ROOT); } + private String normalizePermissionEffect(String value) { + String effect = clean(value).isBlank() ? "ALLOW" : normalize(value); + if (!PERMISSION_EFFECTS.contains(effect)) { + throw new AppException("허용되지 않은 권한 효과입니다: " + effect); + } + return effect; + } + private String normalizeNullable(String value) { String cleaned = clean(value); return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java index 42f8ba6..10c3bb5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java @@ -102,6 +102,7 @@ public class PermissionController { public String save( @RequestParam long roleId, @RequestParam String objectRef, + @RequestParam(defaultValue = "ALLOW") String permissionEffect, @RequestParam(required = false) List ruleColumn, @RequestParam List ruleType, @RequestParam(required = false) List ruleValue, @@ -113,6 +114,7 @@ public class PermissionController { roleId, objectId, "SELECT", + permissionEffect, buildRules(ruleColumn, ruleType, ruleValue), splitColumns(visibleColumns) )); diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 2ce05d6..d31eaf8 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -35,6 +35,7 @@ o.object_id, p.target_name AS object_name, p.action_name AS action, + NVL(p.permission_effect, 'ALLOW') AS permission_effect, LISTAGG( NVL2(pr.rule_column, pr.rule_column || ' ', '') || pr.rule_type || @@ -74,7 +75,7 @@ JOIN cb_app_role r ON r.role_id = p.role_id LEFT JOIN cb_protected_object o ON o.object_name = p.target_name LEFT JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id - GROUP BY p.perm_id, r.role_id, r.role_name, o.object_id, p.target_name, p.action_name + GROUP BY p.perm_id, r.role_id, r.role_name, o.object_id, p.target_name, p.action_name, p.permission_effect ORDER BY r.role_name, p.target_name @@ -104,7 +105,8 @@ SELECT p.perm_id AS permission_id, p.role_id, o.object_id, - p.action_name AS action + p.action_name AS action, + NVL(p.permission_effect, 'ALLOW') AS permission_effect FROM cb_permission p JOIN cb_protected_object o ON o.object_name = p.target_name WHERE p.role_id = #{roleId} @@ -120,8 +122,8 @@ - INSERT INTO cb_permission (perm_id, role_id, target_name, action_name) - SELECT #{permissionId}, #{roleId}, object_name, #{action} + INSERT INTO cb_permission (perm_id, role_id, target_name, action_name, permission_effect) + SELECT #{permissionId}, #{roleId}, object_name, #{action}, #{permissionEffect} FROM cb_protected_object WHERE object_id = #{objectId} @@ -132,6 +134,12 @@ WHERE perm_id = #{permissionId} + + UPDATE cb_permission + SET permission_effect = #{permissionEffect} + WHERE perm_id = #{permissionId} + + DELETE FROM cb_permission_rule WHERE perm_id = #{permissionId} diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index cd048e7..d42995d 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -37,6 +37,13 @@ +
행 규칙
@@ -80,6 +87,7 @@ 역할 테이블/뷰 Action + Effect 행 규칙 적용 필터 NULL 제외 컬럼 @@ -92,6 +100,11 @@ HR_DEPT_ROLE CB_V_SEARCH_DOCUMENTS SELECT + + ALLOW + ALL
ALL ROWS
CONTENTS @@ -104,7 +117,7 @@ - 등록된 권한이 없습니다. + 등록된 권한이 없습니다. diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java index 3249a24..ac195fd 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java @@ -52,6 +52,7 @@ class PermissionServiceTest { 10L, 1L, "SELECT", + "ALLOW", List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")), List.of() ); @@ -67,6 +68,7 @@ class PermissionServiceTest { 10L, 1L, "SELECT", + "ALLOW", List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")), List.of() ); @@ -82,6 +84,7 @@ class PermissionServiceTest { 10L, 1L, "SELECT", + "ALLOW", List.of(new RuleCommand(null, "MY_DEPT", null), new RuleCommand(null, "SELF", null)), List.of() ); @@ -103,6 +106,7 @@ class PermissionServiceTest { 10L, 1L, "SELECT", + "ALLOW", List.of(new RuleCommand(null, "DEPT", "HR"), new RuleCommand(null, "EMP_NO", "E2001")), List.of() ); @@ -124,6 +128,7 @@ class PermissionServiceTest { 10L, 1L, "SELECT", + "ALLOW", List.of(new RuleCommand(null, "DEPT", "")), List.of() ); @@ -133,6 +138,23 @@ class PermissionServiceTest { .hasMessageContaining("값이 필요"); } + @Test + void acceptsDenyPermissionEffect() { + var command = new PermissionSetCommand( + 10L, + 1L, + "SELECT", + "DENY", + List.of(new RuleCommand("DEPT_CODE", "=", "HR")), + List.of() + ); + + permissionService.savePermissionSet(command); + + FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper; + assertThat(mapper.insertedEffect).isEqualTo("DENY"); + } + @Test void disablesProtectedObjectWhenLastPermissionIsDeleted() { var mapper = new FakePermissionMapper(); @@ -209,14 +231,22 @@ class PermissionServiceTest { return 0; } + private String insertedEffect; + @Override - public void insertPermission(long permissionId, long roleId, long objectId, String action) { + public void insertPermission(long permissionId, long roleId, long objectId, String action, String permissionEffect) { + insertedEffect = permissionEffect; } @Override public void updatePermissionAction(long permissionId, String action) { } + @Override + public void updatePermissionEffect(long permissionId, String permissionEffect) { + insertedEffect = permissionEffect; + } + @Override public void deleteRules(long permissionId) { }