feat #466: add deny permission effect

This commit is contained in:
devmrko
2026-06-25 22:24:18 +09:00
parent 1f924f67de
commit d6c7533edd
15 changed files with 224 additions and 42 deletions

View File

@@ -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는 `(<allow predicate>) AND NOT (<deny predicate>)`이다.
- allow `ALL``1 = 1`, deny `ALL`은 즉시 `1 = 0`이다.
- deny만 있고 allow가 없으면 whitelist 기본값에 따라 `1 = 0`이다.

View File

@@ -65,7 +65,8 @@ CREATE TABLE cb_permission (
perm_id NUMBER PRIMARY KEY, perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL REFERENCES cb_app_role(role_id), role_id NUMBER NOT NULL REFERENCES cb_app_role(role_id),
target_name VARCHAR2(128) NOT NULL, 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 ( 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 (102, 20);
INSERT INTO cb_user_role VALUES (103, 30); 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 (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); 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'); 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 (1000, 100, 'MY_DEPT', NULL);
INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL); INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL);
@@ -256,7 +257,8 @@ AUTHID DEFINER
AS AS
v_user_id NUMBER; v_user_id NUMBER;
v_target VARCHAR2(128); 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 FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
BEGIN BEGIN
@@ -294,16 +296,22 @@ AS
RETURN NULL; RETURN NULL;
END; END;
PROCEDURE append_or(p_clause IN VARCHAR2) IS PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN BEGIN
IF p_clause IS NULL THEN IF p_clause IS NULL THEN
RETURN; RETURN;
END IF; END IF;
IF v_predicate IS NULL THEN IF p_effect = 'DENY' THEN
v_predicate := '(' || p_clause || ')'; IF v_deny_predicate IS NULL THEN
v_deny_predicate := '(' || p_clause || ')';
ELSE ELSE
v_predicate := v_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 || ')';
ELSE
v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')';
END IF; END IF;
END; END;
BEGIN BEGIN
@@ -323,6 +331,7 @@ BEGIN
FOR r IN ( FOR r IN (
SELECT UPPER(TRIM(r.rule_type)) AS rule_type, SELECT UPPER(TRIM(r.rule_type)) AS rule_type,
UPPER(TRIM(r.rule_column)) AS rule_column, UPPER(TRIM(r.rule_column)) AS rule_column,
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
r.rule_value r.rule_value
FROM cb_user_role ur FROM cb_user_role ur
JOIN cb_permission p JOIN cb_permission p
@@ -338,46 +347,56 @@ BEGIN
v_column VARCHAR2(128); v_column VARCHAR2(128);
BEGIN BEGIN
IF r.rule_type = 'ALL' THEN 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 ELSIF r.rule_type = 'MY_DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = 'SELF' THEN ELSIF r.rule_type = 'SELF' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = 'DEPT' THEN ELSIF r.rule_type = 'DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = 'EMP_NO' THEN ELSIF r.rule_type = 'EMP_NO' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = '=' THEN ELSIF r.rule_type = '=' THEN
v_column := safe_column(p_schema, p_object, r.rule_column); v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN 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;
ELSIF r.rule_type IN ('!=', '<>') THEN ELSIF r.rule_type IN ('!=', '<>') THEN
v_column := safe_column(p_schema, p_object, r.rule_column); v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN 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 IF; END IF;
END; END;
END LOOP; END LOOP;
IF v_predicate IS NULL THEN IF v_allow_predicate IS NULL THEN
RETURN '1 = 0'; RETURN '1 = 0';
END IF; 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; END;
/ /

View File

@@ -52,6 +52,16 @@ EXCEPTION
END; 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 ( CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY, object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL, owner VARCHAR2(128) NOT NULL,

View File

@@ -22,7 +22,8 @@ AS
-- no matched stored row rule means no rows are visible. -- no matched stored row rule means no rows are visible.
v_user_id NUMBER; v_user_id NUMBER;
v_target VARCHAR2(128); 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 FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
BEGIN BEGIN
@@ -60,16 +61,22 @@ AS
RETURN NULL; RETURN NULL;
END; END;
PROCEDURE append_or(p_clause IN VARCHAR2) IS PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN BEGIN
IF p_clause IS NULL THEN IF p_clause IS NULL THEN
RETURN; RETURN;
END IF; END IF;
IF v_predicate IS NULL THEN IF p_effect = 'DENY' THEN
v_predicate := '(' || p_clause || ')'; IF v_deny_predicate IS NULL THEN
v_deny_predicate := '(' || p_clause || ')';
ELSE ELSE
v_predicate := v_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 || ')';
ELSE
v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')';
END IF; END IF;
END; END;
BEGIN BEGIN
@@ -89,6 +96,7 @@ BEGIN
FOR r IN ( FOR r IN (
SELECT UPPER(TRIM(r.rule_type)) AS rule_type, SELECT UPPER(TRIM(r.rule_type)) AS rule_type,
UPPER(TRIM(r.rule_column)) AS rule_column, UPPER(TRIM(r.rule_column)) AS rule_column,
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
r.rule_value r.rule_value
FROM cb_user_role ur FROM cb_user_role ur
JOIN cb_permission p JOIN cb_permission p
@@ -104,48 +112,58 @@ BEGIN
v_column VARCHAR2(128); v_column VARCHAR2(128);
BEGIN BEGIN
IF r.rule_type = 'ALL' THEN 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 ELSIF r.rule_type = 'MY_DEPT' THEN
-- NULL rule_column keeps backward compatibility with the demo seed. -- NULL rule_column keeps backward compatibility with the demo seed.
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = 'SELF' THEN ELSIF r.rule_type = 'SELF' THEN
-- NULL rule_column keeps backward compatibility with the demo seed. -- 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')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = 'DEPT' THEN ELSIF r.rule_type = 'DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = 'EMP_NO' THEN ELSIF r.rule_type = 'EMP_NO' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO')); v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
IF v_column IS NOT NULL THEN 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; END IF;
ELSIF r.rule_type = '=' THEN ELSIF r.rule_type = '=' THEN
v_column := safe_column(p_schema, p_object, r.rule_column); v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN 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;
ELSIF r.rule_type IN ('!=', '<>') THEN ELSIF r.rule_type IN ('!=', '<>') THEN
v_column := safe_column(p_schema, p_object, r.rule_column); v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN 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 IF; END IF;
END; END;
END LOOP; END LOOP;
IF v_predicate IS NULL THEN IF v_allow_predicate IS NULL THEN
RETURN '1 = 0'; RETURN '1 = 0';
END IF; 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; END;
/ /

View File

@@ -61,6 +61,12 @@ BEGIN
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) 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'); 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 (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 (456102, 'RULE_TEST_SELF');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456103, 'RULE_TEST_ALL'); 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 (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 (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 (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 (456101, 456101);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102); 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 (456108, 456108);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456109, 456109); 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 (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) INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); 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) INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456110, 456110, 'BOARD_POSTS', 'SELECT'); 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) INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456101, 456101, NULL, 'MY_DEPT', NULL); 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) INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456110, 456110, NULL, 'ALL', NULL); 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; COMMIT;
END; END;
/ /
@@ -229,6 +257,9 @@ BEGIN
assert_equals('COLUMN_INJECTION_DENIES', 456109, '1 = 0'); 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_MISMATCH_DENIES', 456110, 'CB_V_SEARCH_DOCUMENTS', '1 = 0');
assert_equals_for_object('TARGET_MATCH_ALL_ALLOWS', 456110, 'BOARD_POSTS', '1 = 1'); 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; cb_agent_ctx_pkg.clear_user;
END; END;
/ /

View File

@@ -7,6 +7,7 @@ public record PermissionSet(
long roleId, long roleId,
long objectId, long objectId,
String action, String action,
String permissionEffect,
List<PermissionRule> rules, List<PermissionRule> rules,
List<String> visibleColumns List<String> visibleColumns
) { ) {

View File

@@ -9,6 +9,7 @@ public record PermissionSetCommand(
@Positive long roleId, @Positive long roleId,
@Positive long objectId, @Positive long objectId,
@NotBlank String action, @NotBlank String action,
String permissionEffect,
@NotEmpty List<RuleCommand> rules, @NotEmpty List<RuleCommand> rules,
List<String> visibleColumns List<String> visibleColumns
) { ) {

View File

@@ -7,6 +7,7 @@ public record PermissionView(
long objectId, long objectId,
String objectName, String objectName,
String action, String action,
String permissionEffect,
String rules, String rules,
String visibleColumns, String visibleColumns,
String filterPreview, String filterPreview,

View File

@@ -36,10 +36,14 @@ public interface PermissionMapper {
void insertPermission(@Param("permissionId") long permissionId, void insertPermission(@Param("permissionId") long permissionId,
@Param("roleId") long roleId, @Param("roleId") long roleId,
@Param("objectId") long objectId, @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 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 deleteRules(@Param("permissionId") long permissionId);
void insertRule(PermissionRule rule); void insertRule(PermissionRule rule);

View File

@@ -56,9 +56,12 @@ public class BackofficeSchemaService {
perm_id NUMBER PRIMARY KEY, perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL, role_id NUMBER NOT NULL,
target_name VARCHAR2(128) 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", """ createTable(results, "cb_permission_rule", """
CREATE TABLE cb_permission_rule ( CREATE TABLE cb_permission_rule (
rule_id NUMBER PRIMARY KEY, rule_id NUMBER PRIMARY KEY,

View File

@@ -22,6 +22,7 @@ public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO"); private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO"); private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO"); private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO");
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
private final PermissionMapper permissionMapper; private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
@@ -69,6 +70,7 @@ public class PermissionService {
if (!"SELECT".equalsIgnoreCase(command.action())) { if (!"SELECT".equalsIgnoreCase(command.action())) {
throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다."); throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다.");
} }
String permissionEffect = normalizePermissionEffect(command.permissionEffect());
AppRole role = permissionMapper.findRole(command.roleId()); AppRole role = permissionMapper.findRole(command.roleId());
if (role == null) { if (role == null) {
throw new AppException("역할을 찾을 수 없습니다."); throw new AppException("역할을 찾을 수 없습니다.");
@@ -80,9 +82,10 @@ public class PermissionService {
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId()); Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId; long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
if (existingId == null) { if (existingId == null) {
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT"); permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect);
} else { } else {
permissionMapper.updatePermissionAction(permissionId, "SELECT"); permissionMapper.updatePermissionAction(permissionId, "SELECT");
permissionMapper.updatePermissionEffect(permissionId, permissionEffect);
} }
permissionMapper.deleteRules(permissionId); permissionMapper.deleteRules(permissionId);
@@ -107,7 +110,7 @@ public class PermissionService {
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null, "PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
"roleId=" + command.roleId() "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 @Transactional
@@ -185,6 +188,14 @@ public class PermissionService {
return clean(value).toUpperCase(Locale.ROOT); 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) { private String normalizeNullable(String value) {
String cleaned = clean(value); String cleaned = clean(value);
return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT); return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT);

View File

@@ -102,6 +102,7 @@ public class PermissionController {
public String save( public String save(
@RequestParam long roleId, @RequestParam long roleId,
@RequestParam String objectRef, @RequestParam String objectRef,
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
@RequestParam(required = false) List<String> ruleColumn, @RequestParam(required = false) List<String> ruleColumn,
@RequestParam List<String> ruleType, @RequestParam List<String> ruleType,
@RequestParam(required = false) List<String> ruleValue, @RequestParam(required = false) List<String> ruleValue,
@@ -113,6 +114,7 @@ public class PermissionController {
roleId, roleId,
objectId, objectId,
"SELECT", "SELECT",
permissionEffect,
buildRules(ruleColumn, ruleType, ruleValue), buildRules(ruleColumn, ruleType, ruleValue),
splitColumns(visibleColumns) splitColumns(visibleColumns)
)); ));

View File

@@ -35,6 +35,7 @@
o.object_id, o.object_id,
p.target_name AS object_name, p.target_name AS object_name,
p.action_name AS action, p.action_name AS action,
NVL(p.permission_effect, 'ALLOW') AS permission_effect,
LISTAGG( LISTAGG(
NVL2(pr.rule_column, pr.rule_column || ' ', '') || NVL2(pr.rule_column, pr.rule_column || ' ', '') ||
pr.rule_type || pr.rule_type ||
@@ -74,7 +75,7 @@
JOIN cb_app_role r ON r.role_id = p.role_id 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_protected_object o ON o.object_name = p.target_name
LEFT JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id 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 ORDER BY r.role_name, p.target_name
</select> </select>
@@ -104,7 +105,8 @@
SELECT p.perm_id AS permission_id, SELECT p.perm_id AS permission_id,
p.role_id, p.role_id,
o.object_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 FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.role_id = #{roleId} WHERE p.role_id = #{roleId}
@@ -120,8 +122,8 @@
</select> </select>
<insert id="insertPermission"> <insert id="insertPermission">
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name) INSERT INTO cb_permission (perm_id, role_id, target_name, action_name, permission_effect)
SELECT #{permissionId}, #{roleId}, object_name, #{action} SELECT #{permissionId}, #{roleId}, object_name, #{action}, #{permissionEffect}
FROM cb_protected_object FROM cb_protected_object
WHERE object_id = #{objectId} WHERE object_id = #{objectId}
</insert> </insert>
@@ -132,6 +134,12 @@
WHERE perm_id = #{permissionId} WHERE perm_id = #{permissionId}
</update> </update>
<update id="updatePermissionEffect">
UPDATE cb_permission
SET permission_effect = #{permissionEffect}
WHERE perm_id = #{permissionId}
</update>
<delete id="deleteRules"> <delete id="deleteRules">
DELETE FROM cb_permission_rule DELETE FROM cb_permission_rule
WHERE perm_id = #{permissionId} WHERE perm_id = #{permissionId}

View File

@@ -37,6 +37,13 @@
</optgroup> </optgroup>
</select> </select>
</label> </label>
<label>
권한 효과
<select class="form-select" name="permissionEffect" required>
<option value="ALLOW">허용</option>
<option value="DENY">거부</option>
</select>
</label>
<div class="span-2"> <div class="span-2">
<div class="field-block-title">행 규칙</div> <div class="field-block-title">행 규칙</div>
<div id="rowRuleList" class="rule-list"> <div id="rowRuleList" class="rule-list">
@@ -80,6 +87,7 @@
<th>역할</th> <th>역할</th>
<th>테이블/뷰</th> <th>테이블/뷰</th>
<th>Action</th> <th>Action</th>
<th>Effect</th>
<th>행 규칙</th> <th>행 규칙</th>
<th>적용 필터</th> <th>적용 필터</th>
<th>NULL 제외 컬럼</th> <th>NULL 제외 컬럼</th>
@@ -92,6 +100,11 @@
<td th:text="${permission.roleName()}">HR_DEPT_ROLE</td> <td th:text="${permission.roleName()}">HR_DEPT_ROLE</td>
<td th:text="${permission.objectName()}">CB_V_SEARCH_DOCUMENTS</td> <td th:text="${permission.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
<td th:text="${permission.action()}">SELECT</td> <td th:text="${permission.action()}">SELECT</td>
<td>
<span class="badge"
th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'"
th:text="${permission.permissionEffect()}">ALLOW</span>
</td>
<td th:text="${permission.rules()} ?: '-'">ALL</td> <td th:text="${permission.rules()} ?: '-'">ALL</td>
<td><pre class="table-pre" th:text="${permission.filterPreview()} ?: '-'">ALL ROWS</pre></td> <td><pre class="table-pre" th:text="${permission.filterPreview()} ?: '-'">ALL ROWS</pre></td>
<td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td> <td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td>
@@ -104,7 +117,7 @@
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(permissions)}"> <tr th:if="${#lists.isEmpty(permissions)}">
<td colspan="8" class="text-muted">등록된 권한이 없습니다.</td> <td colspan="9" class="text-muted">등록된 권한이 없습니다.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -52,6 +52,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")), List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")),
List.of() List.of()
); );
@@ -67,6 +68,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")), List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")),
List.of() List.of()
); );
@@ -82,6 +84,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand(null, "MY_DEPT", null), new RuleCommand(null, "SELF", null)), List.of(new RuleCommand(null, "MY_DEPT", null), new RuleCommand(null, "SELF", null)),
List.of() List.of()
); );
@@ -103,6 +106,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand(null, "DEPT", "HR"), new RuleCommand(null, "EMP_NO", "E2001")), List.of(new RuleCommand(null, "DEPT", "HR"), new RuleCommand(null, "EMP_NO", "E2001")),
List.of() List.of()
); );
@@ -124,6 +128,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand(null, "DEPT", "")), List.of(new RuleCommand(null, "DEPT", "")),
List.of() List.of()
); );
@@ -133,6 +138,23 @@ class PermissionServiceTest {
.hasMessageContaining("값이 필요"); .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 @Test
void disablesProtectedObjectWhenLastPermissionIsDeleted() { void disablesProtectedObjectWhenLastPermissionIsDeleted() {
var mapper = new FakePermissionMapper(); var mapper = new FakePermissionMapper();
@@ -209,14 +231,22 @@ class PermissionServiceTest {
return 0; return 0;
} }
private String insertedEffect;
@Override @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 @Override
public void updatePermissionAction(long permissionId, String action) { public void updatePermissionAction(long permissionId, String action) {
} }
@Override
public void updatePermissionEffect(long permissionId, String permissionEffect) {
insertedEffect = permissionEffect;
}
@Override @Override
public void deleteRules(long permissionId) { public void deleteRules(long permissionId) {
} }