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 7abdfdf..f371628 100644 --- a/sql/adb/17_agent_ords_security_local_vpd_setup.sql +++ b/sql/adb/17_agent_ords_security_local_vpd_setup.sql @@ -71,6 +71,7 @@ CREATE TABLE cb_permission ( CREATE TABLE cb_permission_rule ( rule_id NUMBER PRIMARY KEY, perm_id NUMBER NOT NULL REFERENCES cb_permission(perm_id), + rule_column VARCHAR2(128), rule_type VARCHAR2(30) NOT NULL, rule_value VARCHAR2(100) ); @@ -293,6 +294,24 @@ BEGIN r.rule_type = ''EMP_NO'' AND r.rule_value = owner_emp_no ) + OR ( + r.rule_type = ''='' + AND ( + (r.rule_column = ''DOC_ID'' AND TO_CHAR(doc_id) = r.rule_value) + OR (r.rule_column = ''TITLE'' AND title = r.rule_value) + OR (r.rule_column = ''OWNER_EMP_NO'' AND owner_emp_no = r.rule_value) + OR (r.rule_column = ''DEPT_CODE'' AND dept_code = r.rule_value) + ) + ) + OR ( + r.rule_type = ''!='' + AND ( + (r.rule_column = ''DOC_ID'' AND TO_CHAR(doc_id) != r.rule_value) + OR (r.rule_column = ''TITLE'' AND title != r.rule_value) + OR (r.rule_column = ''OWNER_EMP_NO'' AND owner_emp_no != r.rule_value) + OR (r.rule_column = ''DEPT_CODE'' AND dept_code != r.rule_value) + ) + ) ) )'; END; diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/sql/adb/25_agent_ords_security_backoffice_support.sql index c33e837..811686a 100644 --- a/sql/adb/25_agent_ords_security_backoffice_support.sql +++ b/sql/adb/25_agent_ords_security_backoffice_support.sql @@ -42,6 +42,16 @@ END; / PROMPT === Creating protected object whitelist === +BEGIN + EXECUTE IMMEDIATE 'ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))'; +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/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java index 62f6219..cb08595 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java @@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.domain.permission; public record PermissionRule( long ruleId, long permissionId, + String ruleColumn, String ruleType, String ruleValue ) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java index 11dca96..3483fd4 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java @@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.domain.permission; import jakarta.validation.constraints.NotBlank; public record RuleCommand( + String ruleColumn, @NotBlank String ruleType, String ruleValue ) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index 201f749..ba70218 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -19,7 +19,7 @@ import org.springframework.transaction.annotation.Transactional; @Service public class PermissionService { - private static final Set RULE_TYPES = Set.of("ALL", "MY_DEPT", "SELF", "REGION"); + private static final Set RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF"); private final PermissionMapper permissionMapper; private final ProtectedObjectService protectedObjectService; @@ -72,7 +72,7 @@ public class PermissionService { throw new AppException("역할을 찾을 수 없습니다."); } protectedObjectService.assertEnabled(command.objectId()); - validateRules(command.rules()); + validateRules(command.objectId(), command.rules()); validateVisibleColumns(command.objectId(), command.visibleColumns()); Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId()); @@ -88,6 +88,7 @@ public class PermissionService { permissionMapper.insertRule(new PermissionRule( permissionMapper.nextRuleId(), permissionId, + normalizeNullable(rule.ruleColumn()), normalize(rule.ruleType()), clean(rule.ruleValue()) )); @@ -119,22 +120,30 @@ public class PermissionService { "permissionId=" + permissionId)); } - private void validateRules(List rules) { + private void validateRules(long objectId, List rules) { if (rules == null || rules.isEmpty()) { throw new AppException("행 규칙은 하나 이상 필요합니다."); } Set seen = new HashSet<>(); boolean hasAll = false; + Set allowedColumns = allowedColumns(objectId); for (RuleCommand rule : rules) { String type = normalize(rule.ruleType()); if (!RULE_TYPES.contains(type)) { throw new AppException("허용되지 않은 행 규칙입니다: " + type); } - if (!seen.add(type + ":" + clean(rule.ruleValue()))) { + String column = normalizeNullable(rule.ruleColumn()); + if (!"ALL".equals(type) && !allowedColumns.contains(column)) { + throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column); + } + if (!seen.add(column + ":" + type + ":" + clean(rule.ruleValue()))) { throw new AppException("중복된 행 규칙이 있습니다."); } hasAll = hasAll || "ALL".equals(type); - if (!"ALL".equals(type) && clean(rule.ruleValue()).isBlank()) { + if (!"ALL".equals(type) && column.isBlank()) { + throw new AppException(type + " 규칙에는 컬럼이 필요합니다."); + } + if (("=".equals(type) || "!=".equals(type)) && clean(rule.ruleValue()).isBlank()) { throw new AppException(type + " 규칙에는 값이 필요합니다."); } } @@ -143,6 +152,14 @@ public class PermissionService { } } + private Set allowedColumns(long objectId) { + Set allowed = new HashSet<>(); + for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) { + allowed.add(column.columnName().toUpperCase(Locale.ROOT)); + } + return allowed; + } + private void validateVisibleColumns(long objectId, List visibleColumns) { if (visibleColumns == null || visibleColumns.isEmpty()) { return; @@ -162,6 +179,11 @@ public class PermissionService { return clean(value).toUpperCase(Locale.ROOT); } + private String normalizeNullable(String value) { + String cleaned = clean(value); + return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT); + } + private String clean(String value) { return value == null ? "" : value.trim(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java index e66516e..beb559d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java @@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.service.PermissionService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -29,8 +30,16 @@ public class PermissionController { @GetMapping("/permissions") public String permissions(Model model) { + var objects = protectedObjectService.findEnabled(); model.addAttribute("roles", permissionService.findRoles()); - model.addAttribute("objects", protectedObjectService.findEnabled()); + model.addAttribute("objects", objects); + model.addAttribute("columnsByObject", objects.stream() + .collect(Collectors.toMap( + object -> object.objectId(), + object -> protectedObjectService.findColumns(object.objectId()).stream() + .map(column -> column.columnName()) + .toList() + ))); model.addAttribute("dbObjects", protectedObjectService.findDatabaseObjects()); model.addAttribute("permissions", permissionService.findPermissionViews()); return "permissions"; @@ -40,8 +49,9 @@ public class PermissionController { public String save( @RequestParam long roleId, @RequestParam String objectRef, - @RequestParam String ruleType, - @RequestParam(required = false) String ruleValue, + @RequestParam(required = false) List ruleColumn, + @RequestParam List ruleType, + @RequestParam(required = false) List ruleValue, @RequestParam(required = false) String visibleColumns, RedirectAttributes redirectAttributes ) { @@ -50,13 +60,34 @@ public class PermissionController { roleId, objectId, "SELECT", - List.of(new RuleCommand(ruleType, ruleValue)), + buildRules(ruleColumn, ruleType, ruleValue), splitColumns(visibleColumns) )); redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다."); return "redirect:/permissions"; } + private List buildRules( + List ruleColumns, + List ruleTypes, + List ruleValues + ) { + if (ruleTypes == null || ruleTypes.isEmpty()) { + return List.of(); + } + return java.util.stream.IntStream.range(0, ruleTypes.size()) + .mapToObj(index -> new RuleCommand( + valueAt(ruleColumns, index), + valueAt(ruleTypes, index), + valueAt(ruleValues, index) + )) + .toList(); + } + + private String valueAt(List values, int index) { + return values == null || index >= values.size() ? null : values.get(index); + } + private long resolveObjectId(String objectRef) { if (objectRef == null || objectRef.isBlank()) { throw new IllegalArgumentException("보호 객체를 선택하세요."); diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 7740b52..f0a6766 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -35,7 +35,12 @@ o.object_id, p.target_name AS object_name, p.action_name AS action, - LISTAGG(pr.rule_type || NVL2(pr.rule_value, ':' || pr.rule_value, ''), ', ') + LISTAGG( + NVL2(pr.rule_column, pr.rule_column || ' ', '') || + pr.rule_type || + NVL2(pr.rule_value, ' ' || pr.rule_value, ''), + ', ' + ) WITHIN GROUP (ORDER BY pr.rule_id) AS rules, ( SELECT LISTAGG(pc.column_name, ', ') WITHIN GROUP (ORDER BY pc.column_name) @@ -96,8 +101,14 @@ - INSERT INTO cb_permission_rule (rule_id, perm_id, rule_type, rule_value) - VALUES (#{ruleId}, #{permissionId}, #{ruleType}, #{ruleValue}) + INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES ( + #{ruleId,jdbcType=NUMERIC}, + #{permissionId,jdbcType=NUMERIC}, + #{ruleColumn,jdbcType=VARCHAR}, + #{ruleType,jdbcType=VARCHAR}, + #{ruleValue,jdbcType=VARCHAR} + ) diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index a8d9833..0d2884c 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -168,6 +168,24 @@ body { padding-bottom: 1rem; } +.field-block-title { + color: var(--rw-muted); + font-size: .875rem; + font-weight: 700; + margin-bottom: .35rem; +} + +.rule-list { + display: grid; + gap: .5rem; +} + +.rule-row { + display: grid; + gap: .5rem; + grid-template-columns: 1fr 1fr 1fr auto; +} + .rw-btn, .btn { border-radius: 999px; diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 72435ae..0412d2b 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -28,10 +28,49 @@ function filterUserRoleDetail() { empty.hidden = shown !== 0; } +function syncRuleColumnOptions() { + const objectSelect = document.querySelector('select[name="objectRef"]'); + if (!objectSelect) { + return; + } + const selectedOption = objectSelect.options[objectSelect.selectedIndex]; + const columns = (selectedOption?.dataset.columns || '').split(',').filter(Boolean); + document.querySelectorAll('.rule-column-select').forEach((select) => { + const current = select.value; + select.innerHTML = '' + columns + .map((column) => ``) + .join(''); + if (columns.includes(current)) { + select.value = current; + } + }); +} + document.addEventListener('DOMContentLoaded', () => { const master = document.getElementById('userRoleMaster'); if (master) { master.addEventListener('change', filterUserRoleDetail); filterUserRoleDetail(); } + const objectSelect = document.querySelector('select[name="objectRef"]'); + if (objectSelect) { + objectSelect.addEventListener('change', syncRuleColumnOptions); + syncRuleColumnOptions(); + } + document.querySelectorAll('[data-rule-add]').forEach((button) => { + button.addEventListener('click', () => { + const row = button.closest('.rule-row'); + const list = document.getElementById('rowRuleList'); + if (!row || !list) { + return; + } + const clone = row.cloneNode(true); + clone.querySelectorAll('input').forEach((input) => input.value = ''); + const cloneButton = clone.querySelector('[data-rule-add]'); + cloneButton.textContent = '삭제'; + cloneButton.addEventListener('click', () => clone.remove()); + list.appendChild(clone); + syncRuleColumnOptions(); + }); + }); }); diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index ce3a4ae..d41e6f3 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -27,6 +27,7 @@ @@ -36,23 +37,33 @@ - - -