fix #457: align permission rule UI with VPD filters

This commit is contained in:
devmrko
2026-06-25 21:28:34 +09:00
parent d97dab34bd
commit e347c1bc2f
6 changed files with 201 additions and 12 deletions

View File

@@ -19,7 +19,9 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF");
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> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO");
private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService;
@@ -137,17 +139,17 @@ public class PermissionService {
throw new AppException("허용되지 않은 행 규칙입니다: " + type);
}
String column = normalizeNullable(rule.ruleColumn());
if (!"ALL".equals(type) && !allowedColumns.contains(column)) {
if (column != null && !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) && column.isBlank()) {
if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) {
throw new AppException(type + " 규칙에는 컬럼이 필요합니다.");
}
if (("=".equals(type) || "!=".equals(type)) && clean(rule.ruleValue()).isBlank()) {
if (VALUE_REQUIRED_RULE_TYPES.contains(type) && clean(rule.ruleValue()).isBlank()) {
throw new AppException(type + " 규칙에는 값이 필요합니다.");
}
}

View File

@@ -49,10 +49,12 @@
) AS visible_columns,
(
SELECT LISTAGG(
CASE
CASE
WHEN pr2.rule_type = 'ALL' THEN 'ALL ROWS'
WHEN pr2.rule_type = 'MY_DEPT' THEN pr2.rule_column || ' = SYS_CONTEXT(CB_AGENT_CTX.DEPT_CODE)'
WHEN pr2.rule_type = 'SELF' THEN pr2.rule_column || ' = SYS_CONTEXT(CB_AGENT_CTX.EMP_NO)'
WHEN pr2.rule_type = 'MY_DEPT' THEN NVL(pr2.rule_column, 'DEPT_CODE') || ' = SYS_CONTEXT(CB_AGENT_CTX.DEPT_CODE)'
WHEN pr2.rule_type = 'SELF' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = SYS_CONTEXT(CB_AGENT_CTX.EMP_NO)'
WHEN pr2.rule_type = 'DEPT' THEN NVL(pr2.rule_column, 'DEPT_CODE') || ' = ' || pr2.rule_value
WHEN pr2.rule_type = 'EMP_NO' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = ' || pr2.rule_value
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
END,
CHR(10) || 'AND '

View File

@@ -150,7 +150,7 @@ function filterUserRoleDetail() {
function renderRuleColumnOptions(columns) {
document.querySelectorAll('.rule-column-select').forEach((select) => {
const current = select.value;
select.innerHTML = '<option value="">전체 행</option>' + columns
select.innerHTML = '<option value="">기본 컬럼</option>' + columns
.map((column) => `<option value="${column}">${column}</option>`)
.join('');
if (columns.includes(current)) {
@@ -159,6 +159,38 @@ function renderRuleColumnOptions(columns) {
});
}
function syncRuleTypeHints(root = document) {
root.querySelectorAll('.rule-row').forEach((row) => {
const typeSelect = row.querySelector('.rule-type-select');
const columnSelect = row.querySelector('.rule-column-select');
const valueInput = row.querySelector('input[name="ruleValue"]');
if (!typeSelect || !columnSelect || !valueInput) {
return;
}
const type = typeSelect.value;
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO'].includes(type);
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO'].includes(type);
const placeholderByType = {
ALL: '값 불필요',
MY_DEPT: '값 불필요',
SELF: '값 불필요',
DEPT: '예: HR',
EMP_NO: '예: E2001',
'=': '비교 값',
'!=': '비교 값'
};
columnSelect.required = !columnOptional;
valueInput.required = valueRequired;
valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF'].includes(type);
valueInput.placeholder = placeholderByType[type] || '비교 값';
if (valueInput.disabled) {
valueInput.value = '';
}
});
}
async function syncRuleColumnOptions() {
const objectSelect = document.querySelector('select[name="objectRef"]');
if (!objectSelect) {
@@ -213,6 +245,10 @@ document.addEventListener('DOMContentLoaded', () => {
objectSelect.addEventListener('change', syncRuleColumnOptions);
syncRuleColumnOptions();
}
document.querySelectorAll('.rule-type-select').forEach((select) => {
select.addEventListener('change', () => syncRuleTypeHints());
});
syncRuleTypeHints();
const catalog = document.getElementById('objectCatalogSelect');
if (catalog) {
catalog.addEventListener('change', syncObjectCatalogSelection);
@@ -229,8 +265,12 @@ document.addEventListener('DOMContentLoaded', () => {
const cloneButton = clone.querySelector('[data-rule-add]');
cloneButton.textContent = '삭제';
cloneButton.addEventListener('click', () => clone.remove());
clone.querySelectorAll('.rule-type-select').forEach((select) => {
select.addEventListener('change', () => syncRuleTypeHints());
});
list.appendChild(clone);
syncRuleColumnOptions();
syncRuleTypeHints(clone);
});
});
});

View File

@@ -42,18 +42,20 @@
<div id="rowRuleList" class="rule-list">
<div class="rule-row">
<select class="form-select rule-column-select" name="ruleColumn">
<option value="">전체 행</option>
<option value="">기본 컬럼</option>
<option value="DEPT_CODE">DEPT_CODE</option>
<option value="OWNER_EMP_NO">OWNER_EMP_NO</option>
<option value="DOC_ID">DOC_ID</option>
<option value="TITLE">TITLE</option>
</select>
<select class="form-select" name="ruleType">
<select class="form-select rule-type-select" name="ruleType">
<option value="ALL">ALL</option>
<option value="=">=</option>
<option value="!=">!=</option>
<option value="MY_DEPT">현재 사용자 부서</option>
<option value="SELF">현재 사용자 사번</option>
<option value="DEPT">지정 부서</option>
<option value="EMP_NO">지정 사번</option>
<option value="=">=</option>
<option value="!=">!=</option>
</select>
<input class="form-control" name="ruleValue" placeholder="비교 값">
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>

View File

@@ -14,6 +14,7 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -75,6 +76,63 @@ class PermissionServiceTest {
.hasMessageContaining("허용되지 않은");
}
@Test
void allowsDefaultColumnsForContextRules() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
List.of(new RuleCommand(null, "MY_DEPT", null), new RuleCommand(null, "SELF", null)),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("MY_DEPT", "SELF");
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleColumn)
.containsExactly(null, null);
}
@Test
void acceptsDeptAndEmpNoRulesWithDefaultColumnsAndValues() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
List.of(new RuleCommand(null, "DEPT", "HR"), new RuleCommand(null, "EMP_NO", "E2001")),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("DEPT", "EMP_NO");
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleValue)
.containsExactly("HR", "E2001");
}
@Test
void rejectsDeptRuleWithoutValue() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
List.of(new RuleCommand(null, "DEPT", "")),
List.of()
);
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
.isInstanceOf(AppException.class)
.hasMessageContaining("값이 필요");
}
@Test
void disablesProtectedObjectWhenLastPermissionIsDeleted() {
var mapper = new FakePermissionMapper();
@@ -100,6 +158,8 @@ class PermissionServiceTest {
}
private static class FakePermissionMapper implements PermissionMapper {
private final List<PermissionRule> insertedRules = new ArrayList<>();
@Override
public List<AppRole> findRoles() {
return List.of(new AppRole(10L, "HR_DEPT_ROLE", null));
@@ -163,6 +223,7 @@ class PermissionServiceTest {
@Override
public void insertRule(PermissionRule rule) {
insertedRules.add(rule);
}
@Override